From 3e406d11de95d13298ca829e9db01a800e8bfa13 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Fri, 23 Oct 2026 14:55:30 -0400 Subject: [PATCH 01/20] feat(core): port the wire codec, NIP-42, NIP-43 and validation --- Cargo.lock | 48 ++ Cargo.toml | 5 +- crates/nostream-core/Cargo.toml | 2 + crates/nostream-core/src/adaptive_pow.rs | 204 ++++++++- crates/nostream-core/src/clock.rs | 106 +++++ crates/nostream-core/src/filter.rs | 8 + crates/nostream-core/src/lib.rs | 2 + crates/nostream-core/src/messages.rs | 543 ++++++++++++++++++++++- crates/nostream-core/src/nip05.rs | 112 ++++- crates/nostream-core/src/nip42.rs | 373 +++++++++++++++- crates/nostream-core/src/nip43.rs | 98 +++- crates/nostream-core/src/time.rs | 25 +- crates/nostream-core/src/validation.rs | 138 +++++- crates/nostream-core/tests/wire_codec.rs | 485 ++++++++++++++++++++ 14 files changed, 2055 insertions(+), 94 deletions(-) create mode 100644 crates/nostream-core/src/clock.rs create mode 100644 crates/nostream-core/tests/wire_codec.rs diff --git a/Cargo.lock b/Cargo.lock index e134f127a..293303f88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -365,6 +365,12 @@ dependencies = [ "cmov", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "digest" version = "0.10.7" @@ -1170,6 +1176,7 @@ version = "0.1.0" dependencies = [ "base64", "chacha20", + "getrandom 0.3.4", "hex", "hkdf", "hmac", @@ -1180,16 +1187,26 @@ dependencies = [ "sha2 0.11.1", "thiserror", "tokio", + "url", ] [[package]] name = "nostream-relay" version = "0.1.0" dependencies = [ + "chrono", + "futures-util", + "hex", + "hmac", "nostream-config", "nostream-core", "nostream-storage", + "serde_json", + "sha2 0.11.1", "thiserror", + "tokio", + "tokio-tungstenite", + "tracing", ] [[package]] @@ -1226,15 +1243,18 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "futures-util", "nostream-config", "nostream-core", "nostream-relay", "nostream-storage", "opentelemetry", "opentelemetry-otlp", + "serde_json", "tempfile", "thiserror", "tokio", + "tokio-tungstenite", "tracing", "tracing-opentelemetry", "tracing-subscriber", @@ -2224,6 +2244,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.20" @@ -2401,6 +2433,22 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.10.3", + "sha1", + "thiserror", +] + [[package]] name = "typenum" version = "1.20.1" diff --git a/Cargo.toml b/Cargo.toml index 5a7d1cf6c..9544badfe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,8 @@ chacha20 = "0.10.2" chrono = { version = "0.4", default-features = false, features = ["std", "clock", "serde"] } clap = { version = "4", features = ["derive"] } futures-core = "0.3" +futures-util = "0.3" +getrandom = "0.3" heed = { version = "0.20", features = ["read-txn-no-tls"] } hex = "0.4.3" hkdf = "0.13.0" @@ -40,7 +42,8 @@ sha2 = "0.11.1" sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "postgres", "migrate", "macros", "chrono", "json", "tls-rustls-ring-webpki"] } tempfile = "3" thiserror = "2" -tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] } +tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } +tokio-tungstenite = { version = "0.30", features = ["connect", "handshake"] } tracing = "0.1" tracing-opentelemetry = "0.34" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } diff --git a/crates/nostream-core/Cargo.toml b/crates/nostream-core/Cargo.toml index 4323089a0..3d181c14b 100644 --- a/crates/nostream-core/Cargo.toml +++ b/crates/nostream-core/Cargo.toml @@ -10,6 +10,7 @@ description = "Nostr domain types, wire messages and NIP surfaces for the nostre [dependencies] base64.workspace = true chacha20.workspace = true +getrandom.workspace = true hex.workspace = true hkdf.workspace = true hmac.workspace = true @@ -20,6 +21,7 @@ serde_json.workspace = true sha2.workspace = true thiserror.workspace = true tokio.workspace = true +url.workspace = true [lints] workspace = true diff --git a/crates/nostream-core/src/adaptive_pow.rs b/crates/nostream-core/src/adaptive_pow.rs index 0d5d67800..83eef260c 100644 --- a/crates/nostream-core/src/adaptive_pow.rs +++ b/crates/nostream-core/src/adaptive_pow.rs @@ -1,4 +1,13 @@ -//! Load-aware PoW difficulty. Mirrors `src/utils/adaptive-pow.ts`. +//! Load-aware PoW difficulty. Mirrors `src/utils/adaptive-pow.ts` and +//! `src/utils/wot-pow-policy.ts`. +//! +//! The capture keeps the event rate in module-level state (`rate`, `lastEventAt`) because a +//! relay worker has one process; here it is [`AdaptivePowState`], owned by the relay, so a +//! test can drive it with a fixed clock. The arithmetic is the capture's: the EWMA carries a +//! recency-weighted event count, and the difficulty is scaled from the ratio of the observed +//! per-second rate to `targetEventsPerSecond`. + +use crate::error::CoreError; /// The adaptive-difficulty knobs, copied from the settings section of the same name. #[derive(Debug, Clone, Copy, PartialEq)] @@ -10,15 +19,188 @@ pub struct AdaptivePowConfig { pub period_ms: u64, } -/// The required difficulty for the observed event rate. -/// -/// Phase 1 stub — job 005; will scale difficulty between `floor_bits` and `ceiling_bits` -/// from the EWMA event rate; see `src/utils/adaptive-pow.ts`. +/// One WoT-distance difficulty reduction (`WotPowThreshold`). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct WotPowThreshold { + pub max_distance: u32, + pub difficulty_factor: f64, +} + +/// The in-process EWMA signal and its last event instant (`rate`/`lastEventAt`, +/// `src/utils/adaptive-pow.ts:7-13`). +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct AdaptivePowState { + rate: f64, + last_event_at_ms: f64, +} + +impl AdaptivePowState { + /// Records one accepted event at `now_ms` (`recordEvent`). + pub fn record_event(&mut self, period_ms: u64, now_ms: u64) { + let now = now_ms as f64; + // `Math.max(0, now - lastEventAt)`: with `lastEventAt` at its `0` default the first + // call sees a huge deltaT, which decays the zero rate to nothing. + let delta = (now - self.last_event_at_ms).max(0.0); + + self.rate = calculate_ewma(self.rate, delta, period_ms as f64, 1.0); + self.last_event_at_ms = now; + } + + /// The recency-weighted event count (`getCurrentRate`). + pub fn current_rate(&self) -> f64 { + self.rate + } + + /// The rate as events per second (`getCurrentEventsPerSecond`). + pub fn current_events_per_second(&self, period_ms: u64) -> f64 { + self.rate / (period_ms as f64 / 1000.0 / std::f64::consts::LN_2) + } +} + +/// `R' = R·e^(−ln2/period·Δt) + step` (`calculateEWMA`, `src/utils/ewma-rate-limiter.ts:32-40`). +pub fn calculate_ewma(rate: f64, delta_t: f64, period: f64, step: f64) -> f64 { + let lambda = std::f64::consts::LN_2 / period; + + rate * (-lambda * delta_t).exp() + step +} + +/// The difficulty the observed rate requires (`getCurrentDifficulty`). pub fn compute_difficulty( - _config: &AdaptivePowConfig, - _observed_events_per_second: f64, -) -> Result { - Err(crate::error::CoreError::NotImplemented { - module: "core::adaptive_pow::compute_difficulty", - }) + config: &AdaptivePowConfig, + observed_events_per_second: f64, +) -> Result { + if observed_events_per_second.is_nan() { + return Err(CoreError::InvalidMessage( + "adaptive pow: the event rate is not a number".to_owned(), + )); + } + + if config.target_events_per_second <= 0.0 + || observed_events_per_second <= config.target_events_per_second + { + return Ok(config.floor_bits); + } + + let ratio = observed_events_per_second / config.target_events_per_second; + let span = f64::from(config.ceiling_bits.saturating_sub(config.floor_bits)); + let scaled = f64::from(config.floor_bits) + ((ratio - 1.0) * span).ceil(); + + let bounded = scaled + .max(f64::from(config.floor_bits)) + .min(f64::from(config.ceiling_bits)); + + Ok(bounded as u32) +} + +/// Applies the WoT reduction (`applyWotPowPolicy`): a pubkey outside the trust graph, or +/// with no eligible threshold, pays the computed difficulty in full; the closest eligible +/// threshold wins. +pub fn apply_wot_pow_policy( + computed_difficulty: u32, + distance: Option, + thresholds: Option<&[WotPowThreshold]>, +) -> u32 { + let Some(thresholds) = thresholds.filter(|thresholds| !thresholds.is_empty()) else { + return computed_difficulty; + }; + + let Some(distance) = distance else { + return computed_difficulty; + }; + + let closest = thresholds + .iter() + .filter(|threshold| distance <= threshold.max_distance) + .min_by_key(|threshold| threshold.max_distance); + + match closest { + Some(threshold) => { + let scaled = f64::from(computed_difficulty) * threshold.difficulty_factor; + + scaled.ceil() as u32 + } + None => computed_difficulty, + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use super::*; + + const CONFIG: AdaptivePowConfig = AdaptivePowConfig { + enabled: true, + floor_bits: 0, + ceiling_bits: 24, + target_events_per_second: 50.0, + period_ms: 60_000, + }; + + #[test] + fn a_rate_at_or_below_target_keeps_the_floor() { + assert_eq!(compute_difficulty(&CONFIG, 0.0).unwrap(), 0); + assert_eq!(compute_difficulty(&CONFIG, 50.0).unwrap(), 0); + } + + #[test] + fn the_ratio_scales_between_the_floor_and_the_ceiling() { + // 100/s is 2x the target: 0 + ceil(1 * 24) = 24. + assert_eq!(compute_difficulty(&CONFIG, 100.0).unwrap(), 24); + // 75/s is 1.5x: ceil(0.5 * 24) = 12. + assert_eq!(compute_difficulty(&CONFIG, 75.0).unwrap(), 12); + // Far above the target saturates at the ceiling. + assert_eq!(compute_difficulty(&CONFIG, 1_000_000.0).unwrap(), 24); + } + + #[test] + fn a_zero_target_never_scales() { + let config = AdaptivePowConfig { + target_events_per_second: 0.0, + floor_bits: 3, + ..CONFIG + }; + + assert_eq!(compute_difficulty(&config, 1_000_000.0).unwrap(), 3); + } + + #[test] + fn the_sample_decays_towards_the_target_when_events_stop() { + let mut state = AdaptivePowState::default(); + + for second in 0..100 { + state.record_event(60_000, second * 1_000); + } + + // The EWMA approaches one event per second from below: the steady state is 1.0 and + // the sample is still catching up after 100 half-lives of a second each. + let busy = state.current_events_per_second(60_000); + assert!((0.5..1.0).contains(&busy), "{busy}"); + + // Ten half-lives with no events leave a tenth of the sample. + state.record_event(60_000, 100_000 + 600_000); + let idle = state.current_events_per_second(60_000); + assert!(idle < busy / 10.0, "{idle} after {busy}"); + } + + #[test] + fn the_wot_policy_takes_the_closest_eligible_threshold() { + let thresholds = [ + WotPowThreshold { + max_distance: 1, + difficulty_factor: 0.0, + }, + WotPowThreshold { + max_distance: 2, + difficulty_factor: 0.5, + }, + ]; + + assert_eq!(apply_wot_pow_policy(10, Some(1), Some(&thresholds)), 0); + assert_eq!(apply_wot_pow_policy(10, Some(2), Some(&thresholds)), 5); + assert_eq!(apply_wot_pow_policy(10, Some(3), Some(&thresholds)), 10); + assert_eq!(apply_wot_pow_policy(10, None, Some(&thresholds)), 10); + assert_eq!(apply_wot_pow_policy(10, Some(1), None), 10); + assert_eq!(apply_wot_pow_policy(9, Some(2), Some(&thresholds)), 5); + } } diff --git a/crates/nostream-core/src/clock.rs b/crates/nostream-core/src/clock.rs new file mode 100644 index 000000000..a9aa63004 --- /dev/null +++ b/crates/nostream-core/src/clock.rs @@ -0,0 +1,106 @@ +//! The one clock abstraction. Mirrors the capture's `Date.now()` call sites. +//! +//! No other module reads the system clock: the relay's rate limiters, NIP-42 sessions, +//! `created_at` windows, NIP-40 expiry checks and invite-code expirations take a +//! [`Clock`]. Production uses [`SystemClock`]; tests use [`TestClock`], whose instant can be +//! set and advanced, which is what makes the differential fixture's frozen time replayable. +//! +//! The capture has no seam at all — `Date.now()` is read inside `isExpiredEvent`, the EWMA +//! Lua script, the sliding-window Lua script and the NIP-42 session — so this module is the +//! rewrite's addition, recorded in ADR 0004. + +use std::sync::atomic::{AtomicI64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// The process clock, in Unix milliseconds and seconds. +pub trait Clock: Send + Sync + std::fmt::Debug { + /// The current Unix time in milliseconds — `Date.now()`. + fn now_ms(&self) -> u64; + + /// The current Unix time in whole seconds — `Math.floor(Date.now() / 1000)`. + fn now_secs(&self) -> i64 { + i64::try_from(self.now_ms() / 1000).unwrap_or(i64::MAX) + } +} + +/// The wall clock, used by the daemon. +#[derive(Debug, Clone, Copy, Default)] +pub struct SystemClock; + +impl Clock for SystemClock { + fn now_ms(&self) -> u64 { + // The workspace's one system-clock read; every other module is handed a Clock. + let reading = SystemTime::now(); + reading + .duration_since(UNIX_EPOCH) + .map(|elapsed| u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)) + .unwrap_or(0) + } +} + +/// A settable clock for tests and the differential fixture. +#[derive(Debug, Clone)] +pub struct TestClock { + millis: std::sync::Arc, +} + +impl TestClock { + /// A clock frozen at `start_ms`. + pub fn new(start_ms: u64) -> Self { + Self { + millis: std::sync::Arc::new(AtomicI64::new( + i64::try_from(start_ms).unwrap_or(i64::MAX), + )), + } + } + + /// Moves the clock to `now_ms`. + pub fn set(&self, now_ms: u64) { + self.millis + .store(i64::try_from(now_ms).unwrap_or(i64::MAX), Ordering::SeqCst); + } + + /// Moves the clock forward by `delta_ms`. + pub fn advance(&self, delta_ms: u64) { + let delta = i64::try_from(delta_ms).unwrap_or(i64::MAX); + self.millis.fetch_add(delta, Ordering::SeqCst); + } +} + +impl Clock for TestClock { + fn now_ms(&self) -> u64 { + u64::try_from(self.millis.load(Ordering::SeqCst)).unwrap_or(0) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use super::*; + + #[test] + fn system_clock_is_plausible() { + assert!(SystemClock.now_ms() > 1_600_000_000_000); + assert!(SystemClock.now_secs() > 1_600_000_000); + } + + #[test] + fn test_clock_is_frozen_until_it_moves() { + let clock = TestClock::new(1_700_000_000_000); + + assert_eq!(clock.now_ms(), 1_700_000_000_000); + assert_eq!(clock.now_secs(), 1_700_000_000); + + clock.advance(1_500); + assert_eq!(clock.now_secs(), 1_700_000_001); + assert_eq!( + (*std::sync::Arc::new(clock.clone())).now_ms(), + 1_700_000_001_500 + ); + + clock.set(0); + assert_eq!(clock.now_ms(), 0); + assert_eq!(clock.now_secs(), 0); + } +} diff --git a/crates/nostream-core/src/filter.rs b/crates/nostream-core/src/filter.rs index 1b09e785b..5b3cbd902 100644 --- a/crates/nostream-core/src/filter.rs +++ b/crates/nostream-core/src/filter.rs @@ -157,6 +157,14 @@ impl Filter { } } +/// The number of array-valued criteria a filter carries (`countFilterValues`, +/// `src/utils/filter.ts:9-10`): `ids`, `authors`, `kinds` and every `#`-criterion, each +/// contributing its length; scalar criteria (`since`, `until`, `limit`, `search`) contribute +/// nothing. +pub fn count_filter_values(filter: &Filter) -> usize { + filter.value_count() +} + /// Whether a string is a single-letter tag query, `#` plus one ASCII letter /// (`isGenericTagQuery`, `src/utils/filter.ts:4`). Any other key is ignored by the matcher. pub fn is_generic_tag_query(key: &str) -> bool { diff --git a/crates/nostream-core/src/lib.rs b/crates/nostream-core/src/lib.rs index d57ef0a89..f39dfbd5c 100644 --- a/crates/nostream-core/src/lib.rs +++ b/crates/nostream-core/src/lib.rs @@ -46,6 +46,7 @@ //! it carries. pub mod adaptive_pow; +pub mod clock; pub mod constants; pub mod crypto; pub mod error; @@ -75,6 +76,7 @@ pub mod secret; pub mod time; pub mod validation; +pub use clock::{Clock, SystemClock, TestClock}; pub use error::CoreError; pub use event::{ CanonicalEvent, Event, EventId, Kind, KindSelector, Pubkey, SettingsPubkey, Signature, Tag, diff --git a/crates/nostream-core/src/messages.rs b/crates/nostream-core/src/messages.rs index acfe1fcde..14d507b3b 100644 --- a/crates/nostream-core/src/messages.rs +++ b/crates/nostream-core/src/messages.rs @@ -1,13 +1,22 @@ -//! Client and relay wire messages. Mirrors `src/@types/messages.ts`. +//! Client and relay wire messages. Mirrors `src/@types/messages.ts`, +//! `src/utils/messages.ts` and `src/schemas/message-schema.ts`. //! -//! Phase 1 carries the typed payloads and the message-kind enum. Decoding a wire frame -//! (a positional array whose first element is the kind) is a Phase 2 concern: the parser -//! is a labelled stub, and no transport exists yet. +//! The wire form is a positional JSON array whose first element is the verb. [`decode`] +//! parses the five client kinds and reports the schema's message when a frame is malformed; +//! [`RelayMessage::to_text`] is the single encoder every server→client frame goes through, +//! and its bytes are the capture's `JSON.stringify` output. +//! +//! The positional decoder replaces the capture's `attemptValidation(messageSchema)` call +//! (`src/adapters/web-socket-adapter.ts:199`). The zod messages it reports are reproduced +//! from `src/schemas/{message,event,filter}-schema.ts`; the one deliberate difference is the +//! detail after `invalid: ` for unparseable JSON, where V8's `JSON.parse` diagnosis is not +//! reproducible (ADR 0004 records the tolerance). use serde::{Deserialize, Serialize}; +use serde_json::Value; use crate::event::{Event, EventId, Pubkey}; -use crate::filter::{Filter, SubscriptionId}; +use crate::filter::{Filter, SubscriptionId, validate_filter}; /// The wire message kinds (`src/@types/messages.ts` `MessageType`). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -56,50 +65,542 @@ pub struct NoticeMessage { pub message: String, } -/// `["OK", , , ]` (`src/@types/messages.ts` `CommandResult`). +/// `["OK", , , , ?]` (`src/@types/messages.ts` +/// `CommandResult`). `params` is the NIP-01 optional slot the `rate-limited` backoff fills +/// (`createCommandResult`, `src/utils/messages.ts:31-37`). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CommandResult { pub event_id: EventId, pub accepted: bool, pub message: String, + pub params: Option, } -/// `["CLOSED", , ]` (`src/@types/messages.ts` `ClosedMessage`). +/// `["CLOSED", , , ?]` (`src/@types/messages.ts` +/// `ClosedMessage`), with the same optional slot (`createClosedMessage`, +/// `src/utils/messages.ts:45-47`). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ClosedMessage { pub subscription_id: SubscriptionId, pub message: String, + pub params: Option, +} + +/// `["EOSE", ]` (`src/@types/messages.ts` `EndOfStoredEventsNotice`, +/// NIP-15). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EoseMessage { + pub subscription_id: SubscriptionId, +} + +/// `["EVENT", , ]` (`src/@types/messages.ts` `OutgoingEventMessage`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OutgoingEventMessage { + pub subscription_id: SubscriptionId, + pub event: Event, +} + +/// `["COUNT", , {"count": }]` (`src/@types/messages.ts` +/// `CountResultMessage`, NIP-45). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CountResultMessage { + pub subscription_id: SubscriptionId, + pub count: u64, } -/// A message a client sends (`src/@types/messages.ts` `IncomingMessage`). +/// The NIP-42 challenge a relay sends on connect (`src/@types/messages.ts` +/// `AuthChallengeMessage`). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(untagged)] +pub struct AuthChallengeMessage { + pub challenge: String, +} + +/// A relay-signed event published by another relay (`src/@types/messages.ts` `IncomingRelayedEventMessage`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RelayedEvent { + pub event: Event, + pub source: Pubkey, +} + +/// A message a client sends, decoded (`src/@types/messages.ts` `IncomingMessage`). +#[derive(Debug, Clone, PartialEq, Eq)] pub enum ClientMessage { Event(Event), Subscribe(SubscribeMessage), Count(CountMessage), - Close(UnsubscribeMessage), + Unsubscribe(UnsubscribeMessage), Auth(AuthMessage), } +impl ClientMessage { + /// The verb's name, as the capture's `message[0]` switch reads it. + pub fn message_type(&self) -> MessageType { + match self { + Self::Event(_) => MessageType::Event, + Self::Subscribe(_) => MessageType::Req, + Self::Count(_) => MessageType::Count, + Self::Unsubscribe(_) => MessageType::Close, + Self::Auth(_) => MessageType::Auth, + } + } +} + /// A message the relay sends (`src/@types/messages.ts` `OutgoingMessage`). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(untagged)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum RelayMessage { Notice(NoticeMessage), Ok(CommandResult), Closed(ClosedMessage), + Eose(EoseMessage), + Event(OutgoingEventMessage), + Count(CountResultMessage), + Auth(AuthChallengeMessage), } -/// The NIP-42 challenge a relay sends on connect (`src/@types/messages.ts` `AuthChallengeMessage`). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AuthChallengeMessage { - pub challenge: String, +impl RelayMessage { + /// The frame's wire text: `JSON.stringify(message)` for the matching constructor of + /// `src/utils/messages.ts:20-51`, byte for byte. + pub fn to_text(&self) -> String { + match self { + Self::Notice(message) => frame("NOTICE", &["e(&message.message)]), + Self::Ok(result) => match &result.params { + Some(params) => frame( + "OK", + &[ + "e(&result.event_id.to_string()), + &result.accepted.to_string(), + "e(&result.message), + "e(params), + ], + ), + None => frame( + "OK", + &[ + "e(&result.event_id.to_string()), + &result.accepted.to_string(), + "e(&result.message), + ], + ), + }, + Self::Closed(message) => match &message.params { + Some(params) => frame( + "CLOSED", + &[ + "e(&message.subscription_id), + "e(&message.message), + "e(params), + ], + ), + None => frame( + "CLOSED", + &["e(&message.subscription_id), "e(&message.message)], + ), + }, + Self::Eose(message) => frame("EOSE", &["e(&message.subscription_id)]), + Self::Event(message) => { + let event = + serde_json::to_string(&message.event).unwrap_or_else(|_| "null".to_owned()); + + frame("EVENT", &["e(&message.subscription_id), &event]) + } + Self::Count(message) => frame( + "COUNT", + &[ + "e(&message.subscription_id), + &format!("{{\"count\":{}}}", message.count), + ], + ), + Self::Auth(message) => frame("AUTH", &["e(&message.challenge)]), + } + } + + /// The message-level rate-limit frame (`createRateLimitedMessage`, + /// `src/adapters/web-socket-adapter.ts:284-307`): reads are `CLOSED` with the backoff, + /// everything else is the plain notice. + pub fn rate_limited( + verb: Option, + subscription_id: Option<&str>, + retry_after_ms: u64, + ) -> Self { + match (verb, subscription_id) { + (Some(MessageType::Req | MessageType::Count), Some(subscription_id)) => { + Self::Closed(ClosedMessage { + subscription_id: subscription_id.to_owned(), + message: "rate-limited: slow down".to_owned(), + params: Some(retry_after_ms.to_string()), + }) + } + _ => Self::Notice(NoticeMessage { + message: "rate limited".to_owned(), + }), + } + } } -/// A relay-signed event published by another relay (`src/@types/messages.ts` `IncomingRelayedEventMessage`). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RelayedEvent { - pub event: Event, - pub source: Pubkey, +impl From for RelayMessage { + fn from(result: CommandResult) -> Self { + Self::Ok(result) + } +} + +impl From for RelayMessage { + fn from(message: ClosedMessage) -> Self { + Self::Closed(message) + } +} + +impl From for RelayMessage { + fn from(message: NoticeMessage) -> Self { + Self::Notice(message) + } +} + +fn frame(verb: &str, parts: &[&str]) -> String { + let mut text = format!("[{}", quote(verb)); + + for part in parts { + text.push(','); + text.push_str(part); + } + + text.push(']'); + text +} + +fn quote(value: &str) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_owned()) +} + +/// Why a client frame could not be decoded. The capture's adapter logs the message and +/// answers `["NOTICE", "invalid: "]` (`web-socket-adapter.ts:234-240`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WireError { + /// `JSON.parse` rejected the text. V8's diagnosis is not reproducible; only the + /// `invalid: ` prefix is compared (tolerance T1, ADR 0004). + Json(String), + /// The frame parsed but failed `messageSchema`; the message is the schema's first issue. + Validation(String), +} + +impl WireError { + /// The text after `invalid: `. + pub fn detail(&self) -> &str { + match self { + Self::Json(detail) | Self::Validation(detail) => detail, + } + } + + /// The NOTICE the capture sends (`invalid: ${error.message}`). + pub fn notice(&self) -> String { + format!("invalid: {}", self.detail()) + } +} + +impl std::fmt::Display for WireError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.detail()) + } +} + +/// Decodes one client frame: `attemptValidation(messageSchema)(JSON.parse(text))` +/// (`src/schemas/message-schema.ts:46`, `web-socket-adapter.ts:199`). +pub fn decode(text: &str) -> Result { + let value: Value = + serde_json::from_str(text).map_err(|error| WireError::Json(error.to_string()))?; + + let Value::Array(items) = value else { + return Err(WireError::Validation("Invalid input".to_owned())); + }; + + let verb = items.first().and_then(Value::as_str); + + match verb { + Some("EVENT") => { + let event = items.get(1).ok_or_else(|| invalid("Invalid input"))?; + + Ok(ClientMessage::Event(validate_event_value(event)?)) + } + Some("REQ") => { + let (subscription_id, filters) = decode_request(&items, "REQ")?; + + Ok(ClientMessage::Subscribe(SubscribeMessage { + subscription_id, + filters, + })) + } + Some("COUNT") => { + let (subscription_id, filters) = decode_request(&items, "COUNT")?; + + Ok(ClientMessage::Count(CountMessage { + subscription_id, + filters, + })) + } + Some("CLOSE") => { + let Value::String(subscription_id) = items.get(1).unwrap_or(&Value::Null) else { + return Err(invalid("Invalid input")); + }; + + if subscription_id.is_empty() { + return Err(invalid("String must contain at least 1 character(s)")); + } + + Ok(ClientMessage::Unsubscribe(UnsubscribeMessage { + subscription_id: subscription_id.clone(), + })) + } + Some("AUTH") => { + let event = items.get(1).ok_or_else(|| invalid("Invalid input"))?; + + Ok(ClientMessage::Auth(AuthMessage { + event: validate_event_value(event)?, + })) + } + _ => Err(invalid("Invalid input")), + } +} + +/// `reqMessageSchema`/`countMessageSchema`: the id, then one to eleven filters. +fn decode_request(items: &[Value], verb: &str) -> Result<(String, Vec), WireError> { + let Value::String(subscription_id) = items.get(1).unwrap_or(&Value::Null) else { + return Err(invalid("Invalid input")); + }; + + let length = subscription_id.chars().count(); + + if length == 0 { + return Err(invalid("String must contain at least 1 character(s)")); + } + + if length > 256 { + return Err(invalid("String must contain at most 256 character(s)")); + } + + if items.len() < 3 { + return Err(invalid(&format!( + "{verb} message must contain at least one filter" + ))); + } + + if items.len() > 12 { + return Err(invalid(&format!( + "{verb} message must contain at most 12 elements" + ))); + } + + let mut filters = Vec::with_capacity(items.len() - 2); + + for value in &items[2..] { + match validate_filter(value) { + Ok(filter) => filters.push(filter), + Err(issues) => { + let message = issues + .first() + .map(|issue| issue.message.clone()) + .unwrap_or_else(|| "Invalid input".to_owned()); + + return Err(invalid(&message)); + } + } + } + + Ok((subscription_id.clone(), filters)) +} + +/// `eventSchema.parse` at the value level: the shape checks in field order, the `.strict()` +/// rule, and the kind-7/17/10002/geohash refinements of `src/schemas/event-schema.ts`. +/// +/// The typed [`Event`] carries part of this in its field types (hex length and alphabet); +/// this pass reports the schema's own message for whatever is left, which is what the +/// NOTICE carries. +pub fn validate_event_value(value: &Value) -> Result { + let Value::Object(object) = value else { + return Err(invalid(&format!( + "Expected object, received {}", + json_type(value) + ))); + }; + + for key in object.keys() { + if !matches!( + key.as_str(), + "id" | "pubkey" | "created_at" | "kind" | "tags" | "content" | "sig" + ) { + return Err(invalid(&format!("Unrecognized key(s) in object: '{key}'"))); + } + } + + validate_hex_field(object.get("id"), 64)?; + validate_hex_field(object.get("pubkey"), 64)?; + validate_hex_field(object.get("sig"), 128)?; + + // `id` and `pubkey` come back as `Result`s above; `sig` too. Rebuild the typed event + // only once every field has a schema-clean value. + let Some(id) = object.get("id").and_then(Value::as_str) else { + return Err(invalid("Invalid input")); + }; + let Some(pubkey) = object.get("pubkey").and_then(Value::as_str) else { + return Err(invalid("Invalid input")); + }; + let Some(sig) = object.get("sig").and_then(Value::as_str) else { + return Err(invalid("Invalid input")); + }; + let Some(content) = object.get("content").and_then(Value::as_str) else { + return Err(invalid(&format!( + "Expected string, received {}", + json_type(object.get("content").unwrap_or(&Value::Null)) + ))); + }; + + let created_at = match object.get("created_at") { + Some(Value::Number(number)) => match number.as_i64() { + Some(value) if (0..10_000_000_000).contains(&value) => value, + Some(value) if value < 0 => { + return Err(invalid("Number must be greater than or equal to 0")); + } + _ => return Err(invalid("Invalid timestamp")), + }, + other => { + return Err(invalid(&format!( + "Expected number, received {}", + json_type(other.unwrap_or(&Value::Null)) + ))); + } + }; + + let kind = match object.get("kind") { + Some(Value::Number(number)) => match number.as_u64() { + Some(value) => crate::event::Kind(value), + None if number.as_f64().is_some_and(|value| value.fract() != 0.0) => { + return Err(invalid("Expected integer, received float")); + } + None if number.as_i64().is_some_and(|value| value < 0) => { + return Err(invalid("Number must be greater than or equal to 0")); + } + None => return Err(invalid("Number must be greater than or equal to 0")), + }, + other => { + return Err(invalid(&format!( + "Expected number, received {}", + json_type(other.unwrap_or(&Value::Null)) + ))); + } + }; + + let tags = validate_tags(object.get("tags"))?; + + let event = Event { + id: id + .parse() + .map_err(|error: crate::error::CoreError| invalid(&error.to_string()))?, + pubkey: pubkey + .parse() + .map_err(|error: crate::error::CoreError| invalid(&error.to_string()))?, + created_at, + kind, + tags, + content: content.to_owned(), + sig: sig + .parse() + .map_err(|error: crate::error::CoreError| invalid(&error.to_string()))?, + }; + + crate::validation::validate_event(&event) + .map_err(|error| invalid(&strip_invalid_event(&error.to_string())))?; + + Ok(event) +} + +fn validate_hex_field(value: Option<&Value>, length: usize) -> Result<(), WireError> { + let Some(Value::String(text)) = value else { + return Err(invalid(&format!( + "Expected string, received {}", + json_type(value.unwrap_or(&Value::Null)) + ))); + }; + + if !text + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + || text.is_empty() + { + return Err(invalid("Invalid")); + } + + if text.chars().count() != length { + return Err(invalid(&format!( + "String must contain exactly {length} character(s)" + ))); + } + + Ok(()) +} + +fn validate_tags(value: Option<&Value>) -> Result>, WireError> { + let Some(Value::Array(tags)) = value else { + return Err(invalid(&format!( + "Expected array, received {}", + json_type(value.unwrap_or(&Value::Null)) + ))); + }; + + let mut parsed = Vec::with_capacity(tags.len()); + + for tag in tags { + let Value::Array(elements) = tag else { + return Err(invalid(&format!( + "Expected array, received {}", + json_type(tag) + ))); + }; + + if elements.is_empty() { + return Err(invalid("Array must contain at least 1 element(s)")); + } + + let mut parsed_tag = Vec::with_capacity(elements.len()); + + for (index, element) in elements.iter().enumerate() { + let Value::String(text) = element else { + return Err(invalid(&format!( + "Expected string, received {}", + json_type(element) + ))); + }; + + if index == 0 && text.is_empty() { + return Err(invalid("String must contain at least 1 character(s)")); + } + + parsed_tag.push(text.clone()); + } + + parsed.push(parsed_tag); + } + + Ok(parsed) +} + +fn json_type(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +/// `CoreError::InvalidEvent`'s prefix is an internal spelling; the schema message is the +/// text after it. +fn strip_invalid_event(message: &str) -> String { + message + .strip_prefix("invalid event: ") + .unwrap_or(message) + .to_owned() +} + +fn invalid(message: &str) -> WireError { + WireError::Validation(message.to_owned()) } diff --git a/crates/nostream-core/src/nip05.rs b/crates/nostream-core/src/nip05.rs index cf4ce3b3c..40223fe11 100644 --- a/crates/nostream-core/src/nip05.rs +++ b/crates/nostream-core/src/nip05.rs @@ -1,7 +1,117 @@ //! NIP-05: DNS-based identifiers. Mirrors `src/utils/nip05.ts` and `src/@types/nip05.ts`. +//! +//! The pure half is implemented — identifier parsing, extraction from a kind-0 metadata +//! event, and the domain allow/deny rule. [`verify`] is the network fetch and stays a +//! labelled stub: no handler in this job may block on it, and the background refresh that +//! would call it is `nostream-services`' work. use crate::error::CoreError; -use crate::event::Pubkey; +use crate::event::{Event, Kind, Pubkey}; + +/// The default verification lifetime, 7 days (`DEFAULT_NIP05_VERIFY_EXPIRATION_MS`, +/// `src/utils/nip05.ts:21`). +pub const DEFAULT_NIP05_VERIFY_EXPIRATION_MS: i64 = 604_800_000; + +/// The domain grammar `parseNip05Identifier` enforces (`DOMAIN_REGEX`, +/// `src/utils/nip05.ts:17`): labels of alphanumerics and hyphens, then a TLD of two or more +/// letters. +fn is_valid_domain(domain: &str) -> bool { + let labels: Vec<&str> = domain.split('.').collect(); + + if labels.len() < 2 { + return false; + } + + let Some(tld) = labels.last() else { + return false; + }; + + if tld.len() < 2 || !tld.chars().all(|character| character.is_ascii_alphabetic()) { + return false; + } + + labels[..labels.len() - 1].iter().all(|label| { + !label.is_empty() + && label + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '-') + && !label.starts_with('-') + && !label.ends_with('-') + }) +} + +/// A parsed `local@domain` identifier (`parseNip05Identifier`, `src/utils/nip05.ts:56-76`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Nip05Identifier { + pub local_part: String, + pub domain: String, +} + +/// Parses `local@domain`; the local part is lowercased, and the domain must match the +/// capture's grammar. +pub fn parse_identifier(nip05: &str) -> Option { + let at = nip05.rfind('@')?; + + if at == 0 || at == nip05.len() - 1 { + return None; + } + + let local_part = &nip05[..at]; + let domain = &nip05[at + 1..]; + + if local_part.is_empty() || domain.is_empty() || !is_valid_domain(domain) { + return None; + } + + Some(Nip05Identifier { + local_part: local_part.to_lowercase(), + domain: domain.to_lowercase(), + }) +} + +/// The `nip05` field of a kind-0 metadata event's content +/// (`extractNip05FromEvent`, `src/utils/nip05.ts:79-96`). +pub fn extract_from_event(event: &Event) -> Option { + if event.kind != Kind::METADATA { + return None; + } + + let metadata: serde_json::Value = serde_json::from_str(&event.content).ok()?; + + metadata + .get("nip05") + .and_then(serde_json::Value::as_str) + .filter(|nip05| !nip05.is_empty()) + .map(str::to_owned) +} + +/// The domain allow/deny rule (`isDomainAllowed`, `src/utils/nip05.ts:204-219`): the +/// blacklist wins, then a non-empty whitelist must contain the domain, case-insensitively. +pub fn is_domain_allowed( + domain: &str, + whitelist: Option<&[String]>, + blacklist: Option<&[String]>, +) -> bool { + let lower_domain = domain.to_lowercase(); + + if let Some(blacklist) = blacklist.filter(|list| !list.is_empty()) + && blacklist + .iter() + .any(|entry| entry.to_lowercase() == lower_domain) + { + return false; + } + + if let Some(whitelist) = whitelist.filter(|list| !list.is_empty()) + && !whitelist + .iter() + .any(|entry| entry.to_lowercase() == lower_domain) + { + return false; + } + + true +} /// A verification record: the identifier, its pubkey and the verification state. #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/nostream-core/src/nip42.rs b/crates/nostream-core/src/nip42.rs index cb6888609..e70e9d661 100644 --- a/crates/nostream-core/src/nip42.rs +++ b/crates/nostream-core/src/nip42.rs @@ -1,35 +1,368 @@ //! NIP-42: authentication of clients to relays. Mirrors `src/utils/nip42.ts` and //! `src/utils/nip42-session.ts`. +//! +//! [`authenticate`] is the pure check of one kind-22242 event against the connection's +//! challenge and the relay's URL; [`Nip42Session`] is the per-connection state — one +//! challenge, the accepted pubkeys with their authentication instant, the accepted AUTH +//! event ids, and the optional TTL. The read-authorisation guards are here too, because +//! `src/utils/nip42.ts` owns them and `nostream-relay` only wires them. + +use std::collections::{BTreeMap, HashSet}; + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; use crate::error::CoreError; -use crate::event::{Event, Pubkey}; +use crate::event::{Event, EventId, Kind, KindSelector, Pubkey}; +use crate::filter::Filter; + +/// The kinds a client may only read as their author or a p-tagged recipient +/// (`DEFAULT_RESTRICTED_READ_KINDS`, `src/utils/nip42.ts:10-13`): kind 4 and kind 1059. +pub const DEFAULT_RESTRICTED_READ_KINDS: [Kind; 2] = [Kind(4), Kind(1059)]; + +/// `AUTH_EVENT_KIND` (`src/handlers/auth-message-handler.ts:19`). +pub const AUTH_EVENT_KIND: Kind = Kind(22_242); -/// The relay's per-connection authentication state. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct AuthState { - pub challenge: Option, - pub authenticated_pubkey: Option, - pub expires_at: Option, +/// `MAX_TIMESTAMP_DELTA_SECONDS` (`src/handlers/auth-message-handler.ts:20`). +pub const MAX_TIMESTAMP_DELTA_SECONDS: i64 = 600; + +/// Why an AUTH event failed. Each variant carries the capture's reason text, without the +/// `invalid: ` prefix the handler adds (`src/handlers/auth-message-handler.ts:27-62`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthFailure { + /// `invalid: auth event must be kind 22242` + WrongKind, + /// `invalid: event id does not match` + IdMismatch, + /// `invalid: event signature verification failed` + SignatureInvalid, + /// `invalid: created_at is too far from the current time` + TimestampTooFar, + /// `invalid: challenge does not match` + ChallengeMismatch, + /// `invalid: relay url does not match` + RelayMismatch, +} + +impl AuthFailure { + /// The capture's reason text, after `invalid: `. + pub fn message(self) -> &'static str { + match self { + Self::WrongKind => "auth event must be kind 22242", + Self::IdMismatch => "event id does not match", + Self::SignatureInvalid => "event signature verification failed", + Self::TimestampTooFar => "created_at is too far from the current time", + Self::ChallengeMismatch => "challenge does not match", + Self::RelayMismatch => "relay url does not match", + } + } } /// Checks that a kind-22242 event answers this challenge and is signed by its author. /// -/// Phase 1 stub — job 005; will verify the challenge tag, relay tag and signature; see `src/utils/nip42.ts`. +/// The checks run in `AuthMessageHandler.handleMessage`'s order (`:25-62`): kind, id, +/// signature, the ±600 s window, the `challenge` tag, the `relay` tag's hostname. `now` is +/// supplied by the caller because no module reads the system clock +/// ([`crate::messages`] is pure; the relay owns the clock). pub fn authenticate( - _event: &Event, - _challenge: &str, - _relay_url: &str, -) -> Result { - Err(CoreError::NotImplemented { - module: "core::nip42::authenticate", - }) + event: &Event, + challenge: &str, + relay_url: &str, + now: i64, +) -> Result { + if event.kind != AUTH_EVENT_KIND { + return Err(AuthFailure::WrongKind); + } + + let id = crate::crypto::compute_event_id(event).map_err(|_| AuthFailure::IdMismatch)?; + + if id != event.id { + return Err(AuthFailure::IdMismatch); + } + + crate::crypto::verify_event_signature(event).map_err(|_| AuthFailure::SignatureInvalid)?; + + if (now - event.created_at).abs() > MAX_TIMESTAMP_DELTA_SECONDS { + return Err(AuthFailure::TimestampTooFar); + } + + let challenge_tag = event + .tags + .iter() + .find(|tag| tag.len() >= 2 && tag[0] == "challenge"); + + if !challenge_tag.is_some_and(|tag| tag[1] == challenge) { + return Err(AuthFailure::ChallengeMismatch); + } + + let relay_tag = event + .tags + .iter() + .find(|tag| tag.len() >= 2 && tag[0] == "relay"); + + if !relay_tag.is_some_and(|tag| is_relay_url_match(&tag[1], relay_url)) { + return Err(AuthFailure::RelayMismatch); + } + + Ok(event.pubkey) } -/// Generates a fresh challenge for a connection. -/// -/// Phase 1 stub — job 005; will mint a per-connection challenge; see `src/utils/nip42.ts`. +/// NIP-42 says domain-match is sufficient (`isRelayUrlMatch`, +/// `src/handlers/auth-message-handler.ts:81-89`): the hostnames, lowercased, must be equal +/// and both URLs must parse. +pub fn is_relay_url_match(client_relay: &str, server_relay: &str) -> bool { + let hostname = |value: &str| { + url::Url::parse(value) + .ok() + .and_then(|url| url.host_str().map(str::to_ascii_lowercase)) + }; + + match (hostname(client_relay), hostname(server_relay)) { + (Some(client), Some(server)) => client == server, + _ => false, + } +} + +/// Generates a fresh challenge for a connection: 32 OS-random bytes, base64url without +/// padding (`Nip42SessionManager.createChallenge`, `src/utils/nip42-session.ts:29-31`). pub fn generate_challenge() -> Result { - Err(CoreError::NotImplemented { - module: "core::nip42::generate_challenge", + let mut bytes = [0u8; 32]; + + getrandom::fill(&mut bytes) + .map_err(|error| CoreError::InvalidMessage(format!("challenge entropy: {error}")))?; + + Ok(URL_SAFE_NO_PAD.encode(bytes)) +} + +/// The per-connection NIP-42 state (`Nip42SessionManager`, `src/utils/nip42-session.ts`). +/// +/// One challenge per socket, never rotated by AUTH; accepted AUTH event ids are remembered so +/// a replay cannot refresh the session; a pubkey's session expires when +/// `now - authenticatedAt >= ttl`, and `ttl` of zero or less never expires. +#[derive(Debug, Clone)] +pub struct Nip42Session { + challenge: String, + ttl_seconds: i64, + sessions: BTreeMap, + accepted_auth_event_ids: HashSet, +} + +impl Nip42Session { + /// Mints the connection's challenge. `ttl_seconds` is `nip42.sessionExpirySeconds`, + /// where `0` means no expiry. + pub fn new(ttl_seconds: i64) -> Result { + Ok(Self { + challenge: generate_challenge()?, + ttl_seconds, + sessions: BTreeMap::new(), + accepted_auth_event_ids: HashSet::new(), + }) + } + + /// The state for a challenge the caller already has. The differential fixture replays a + /// recorded session, so its challenge must be the recording's, not fresh entropy. + pub fn with_challenge(challenge: impl Into, ttl_seconds: i64) -> Self { + Self { + challenge: challenge.into(), + ttl_seconds, + sessions: BTreeMap::new(), + accepted_auth_event_ids: HashSet::new(), + } + } + + /// The connection's challenge, for the `["AUTH", …]` frame and the AUTH handler. + pub fn challenge(&self) -> &str { + &self.challenge + } + + /// Records a successful AUTH. Returns `false` when this AUTH event id was already + /// accepted on this socket (a replay). + pub fn authenticate(&mut self, pubkey: Pubkey, auth_event_id: EventId, now: i64) -> bool { + if !self.accepted_auth_event_ids.insert(auth_event_id) { + return false; + } + + self.sessions.insert(pubkey, now); + true + } + + /// Clears the whole session (connection close). + pub fn clear(&mut self) { + self.sessions.clear(); + self.accepted_auth_event_ids.clear(); + } + + /// The live pubkeys, after pruning expired sessions. + pub fn authenticated_pubkeys(&mut self, now: i64) -> HashSet { + self.prune_expired(now); + self.sessions.keys().copied().collect() + } + + /// The live pubkeys in stable (pubkey) order, after pruning expired sessions. The + /// capture's `[...set]` yields insertion order; a whitelist pick needs *an* order, and + /// this one cannot vary between runs. + pub fn authenticated_pubkeys_in_order(&mut self, now: i64) -> Vec { + self.prune_expired(now); + self.sessions.keys().copied().collect() + } + + /// Whether one pubkey is authenticated, after pruning expired sessions. + pub fn is_authenticated(&mut self, pubkey: &Pubkey, now: i64) -> bool { + self.prune_expired(now); + self.sessions.contains_key(pubkey) + } + + /// The instant a pubkey authenticated at, after pruning expired sessions. + pub fn authenticated_at(&mut self, pubkey: &Pubkey, now: i64) -> Option { + self.prune_expired(now); + self.sessions.get(pubkey).copied() + } + + fn prune_expired(&mut self, now: i64) { + if self.ttl_seconds <= 0 { + return; + } + + self.sessions + .retain(|_, authenticated_at| now - *authenticated_at < self.ttl_seconds); + } +} + +/// `getRestrictedReadKinds`: the configured kinds when restricted reads are enabled, the +/// NIP-42 defaults otherwise, and none when the feature is off entirely. +pub fn restricted_read_kinds(enabled: bool, configured: &[KindSelector]) -> Vec { + if !enabled { + return Vec::new(); + } + + if configured.is_empty() { + DEFAULT_RESTRICTED_READ_KINDS + .iter() + .map(|kind| KindSelector::Single(*kind)) + .collect() + } else { + configured.to_vec() + } +} + +/// `isEventKindOrRangeMatch` for a restricted-read selector. +fn covers(selector: KindSelector, kind: Kind) -> bool { + match selector { + KindSelector::Single(candidate) => candidate == kind, + KindSelector::Range([first, last]) => kind >= first && kind <= last, + } +} + +/// `isClientAuthorizedToReadMention` (`src/utils/nip42.ts:29-41`): the event's author or a +/// pubkey named by one of its `p` tags must be authenticated. +pub fn is_client_authorized_to_read_mention( + event: &Event, + authenticated: &HashSet, +) -> bool { + if authenticated.is_empty() { + return false; + } + + if authenticated.contains(&event.pubkey) { + return true; + } + + event.tags.iter().any(|tag| { + tag.len() >= 2 + && tag[0] == "p" + && tag[1] + .parse::() + .is_ok_and(|pubkey| authenticated.contains(&pubkey)) + }) +} + +/// `createReadAuthorizationGuard` (`src/utils/nip42.ts:44-58`): unrestricted events pass; +/// a restricted event needs author or p-tag authorisation. +pub fn is_read_authorized( + event: &Event, + restricted_kinds: &[KindSelector], + authenticated: &HashSet, +) -> bool { + if !restricted_kinds + .iter() + .any(|selector| covers(*selector, event.kind)) + { + return true; + } + + is_client_authorized_to_read_mention(event, authenticated) +} + +/// `isSubscriptionAuthRequired` (`src/utils/nip42.ts:71-91`): a REQ whose every filter asks +/// only for restricted kinds can return nothing to an unauthenticated client, so it is +/// closed with `auth-required` instead of served empty. +pub fn is_subscription_auth_required( + restricted_kinds: &[KindSelector], + filters: &[Filter], + authenticated: &HashSet, +) -> bool { + if restricted_kinds.is_empty() { + return false; + } + + let is_fully_restricted = |filter: &Filter| { + filter.kinds.as_ref().is_some_and(|kinds| { + !kinds.is_empty() + && kinds.iter().all(|kind| { + restricted_kinds + .iter() + .any(|selector| covers(*selector, *kind)) + }) + }) + }; + + if filters.is_empty() || !filters.iter().all(is_fully_restricted) { + return false; + } + + authenticated.is_empty() +} + +/// `isCountAuthorized` (`src/utils/nip42.ts:94-115`): a COUNT for restricted kinds must be +/// scoped to the client's own `authors` or `#p` values. +pub fn is_count_authorized( + restricted_kinds: &[KindSelector], + filters: &[Filter], + authenticated: &HashSet, +) -> bool { + if restricted_kinds.is_empty() { + return true; + } + + let restricted_filters: Vec<&Filter> = filters + .iter() + .filter(|filter| { + filter.kinds.as_ref().is_some_and(|kinds| { + kinds.iter().any(|kind| { + restricted_kinds + .iter() + .any(|selector| covers(*selector, *kind)) + }) + }) + }) + .collect(); + + if restricted_filters.is_empty() { + return true; + } + + let is_scoped_to_client = |values: Option<&Vec>| { + values.is_some_and(|values| { + !values.is_empty() + && values.iter().all(|value| { + value + .parse::() + .is_ok_and(|pubkey| authenticated.contains(&pubkey)) + }) + }) + }; + + restricted_filters.iter().all(|filter| { + is_scoped_to_client(filter.authors.as_ref()) || is_scoped_to_client(filter.tags.get("#p")) }) } diff --git a/crates/nostream-core/src/nip43.rs b/crates/nostream-core/src/nip43.rs index 1ba5a4608..af1f0d250 100644 --- a/crates/nostream-core/src/nip43.rs +++ b/crates/nostream-core/src/nip43.rs @@ -1,5 +1,9 @@ //! NIP-43: relay membership and invite codes. Mirrors `src/utils/nip43.ts` and //! `src/utils/nip43-invites.ts`. +//! +//! The pure half is implemented: the invite code's entropy, the limits the settings +//! resolve, and the relay-signed kind-28935 event that answers a REQ. Persisting the code is +//! the REQ handler's call into `InviteCodeRepository`; [`InviteCode`] is the row shape. use crate::error::CoreError; use crate::event::{Event, Kind, Pubkey}; @@ -13,17 +17,93 @@ pub struct InviteCode { pub claimed_by: Vec, } -/// Mints a kind-28935 invite code for an authenticated requester. +/// The default number of uses one minted code carries (`DEFAULT_INVITE_MAX_USES`, +/// `src/utils/nip43-invites.ts:10`). +pub const DEFAULT_INVITE_MAX_USES: u32 = 1; + +/// The default lifetime of one minted code (`DEFAULT_INVITE_CODE_EXPIRY_SECONDS`, +/// `src/utils/nip43-invites.ts:11`). +pub const DEFAULT_INVITE_CODE_EXPIRY_SECONDS: u64 = 600; + +/// The limits one minted code carries (`resolveInviteCodeLimits`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InviteCodeLimits { + pub remaining_uses: u32, + /// Unix seconds, `None` when the code never expires. + pub expires_at: Option, +} + +/// Resolves the limits from `nip43.defaultMaxUses` and `nip43.inviteCodeExpirySeconds` +/// (`resolveInviteCodeLimits`, `src/utils/nip43-invites.ts:47-66`). A non-positive or +/// non-finite expiry means no expiry. +pub fn resolve_invite_limits( + default_max_uses: Option, + expiry_seconds: Option, + now_secs: i64, +) -> Result { + let remaining_uses = default_max_uses.unwrap_or(DEFAULT_INVITE_MAX_USES); + + if remaining_uses < 1 { + return Err(CoreError::InvalidMessage( + "remainingUses must be a positive integer".to_owned(), + )); + } + + let expiry_seconds = expiry_seconds.unwrap_or(DEFAULT_INVITE_CODE_EXPIRY_SECONDS); + let expires_at = (expiry_seconds > 0) + .then(|| i64::try_from(expiry_seconds).unwrap_or(i64::MAX)) + .map(|seconds| now_secs.saturating_add(seconds)); + + Ok(InviteCodeLimits { + remaining_uses, + expires_at, + }) +} + +/// A fresh invite code: 16 random bytes, hex (`generateInviteCode`, +/// `src/utils/nip43-invites.ts:14`). +pub fn generate_invite_code() -> Result { + let mut bytes = [0u8; 16]; + + getrandom::fill(&mut bytes) + .map_err(|error| CoreError::InvalidMessage(format!("invite code entropy: {error}")))?; + + Ok(hex::encode(bytes)) +} + +/// Builds the relay-signed kind-28935 event that answers an invite request +/// (`buildInviteCodeEvent`, `src/utils/nip43-invites.ts:96-121`). /// -/// Phase 1 stub — job 005; will mint and persist an invite code; see `src/utils/nip43-invites.ts`. -pub fn mint_invite( - _requester: &Pubkey, - _max_uses: u32, - _expiry_seconds: u64, +/// The `claim` tag is a bearer secret: the caller sends this event only to the socket that +/// asked for it, and never persists or broadcasts it. +pub fn build_invite_code_event( + secret_key: &[u8; 32], + code: &str, + limits: &InviteCodeLimits, + created_at: i64, ) -> Result { - Err(CoreError::NotImplemented { - module: "core::nip43::mint_invite", - }) + let mut tags = vec![ + vec!["claim".to_owned(), code.to_owned()], + vec!["-".to_owned()], + ]; + + if let Some(expires_at) = limits.expires_at { + tags.push(vec!["expiration".to_owned(), expires_at.to_string()]); + } + + let mut event = Event { + id: crate::event::EventId::from_byte_array([0; 32]), + pubkey: crate::event::Pubkey::from_byte_array([0; 32]), + created_at, + kind: Kind(28_935), + tags, + content: String::new(), + sig: crate::event::Signature::from_byte_array([0; 64]), + }; + + crate::crypto::sign_event(secret_key, &mut event)?; + + Ok(event) } /// Whether a kind is the membership join request (`src/utils/nip43.ts`). diff --git a/crates/nostream-core/src/time.rs b/crates/nostream-core/src/time.rs index 51aaeecd5..203f09f7a 100644 --- a/crates/nostream-core/src/time.rs +++ b/crates/nostream-core/src/time.rs @@ -1,24 +1,12 @@ -//! Reference time and the `created_at` window the relay accepts. +//! The `created_at` window the relay accepts. //! //! Mirrors `src/handlers/event-message-handler.ts:179,203-218` (and its twin in -//! `src/app/static-mirroring-worker.ts:203-219`). The clock is read in one place, -//! [`unix_now`], so every caller can supply a fixed time instead; the capture reads -//! `Date.now()` inside the check because it has no such seam. - -use std::time::{SystemTime, UNIX_EPOCH}; +//! `src/app/static-mirroring-worker.ts:203-219`). The reference time is an argument, never a +//! clock read: the capture reads `Date.now()` inside the check because it has no such seam, +//! and [`crate::clock::Clock`] is where the rewrite reads time. use crate::error::CoreError; -/// The current Unix time in whole seconds — the floor of `Date.now() / 1000`. -/// -/// A clock before the epoch saturates to zero rather than wrapping. -pub fn unix_now() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|elapsed| i64::try_from(elapsed.as_secs()).unwrap_or(i64::MAX)) - .unwrap_or(0) -} - /// Checks `created_at` against the reference time /// (`src/handlers/event-message-handler.ts:203-217`). /// @@ -106,9 +94,4 @@ mod tests { assert!(matches!(error, CoreError::CreatedAtInFuture { .. })); } - - #[test] - fn unix_now_is_plausible() { - assert!(unix_now() > 1_600_000_000); - } } diff --git a/crates/nostream-core/src/validation.rs b/crates/nostream-core/src/validation.rs index 4c6e15347..1b47b33d2 100644 --- a/crates/nostream-core/src/validation.rs +++ b/crates/nostream-core/src/validation.rs @@ -1,15 +1,133 @@ -//! Event validation against the base schema. Mirrors `src/utils/validation.ts` and -//! `src/utils/event.ts` (`validateEvent`). +//! Event validation against the base schema. Mirrors `src/utils/validation.ts`, +//! `src/schemas/event-schema.ts` and the id/signature half of `src/utils/event.ts` +//! (`isEventIdValid`, `isEventSignatureValid`). +//! +//! [`validate_event`] is the schema check the codec runs before a frame reaches a handler; +//! [`validate_event_fields`] is the same check at the value level, where it can report the +//! schema's own message for a malformed field (the NOTICE carries it). The two share +//! [`check_event_shape`], so the typed and untyped entry points cannot disagree. use crate::error::CoreError; -use crate::event::Event; +use crate::event::{Event, Kind, Tag}; -/// Validates id/sig shape, kind bounds and tag structure before storage. +/// The geohash alphabet (`GEOHASH_BASE32`, `src/utils/geohash.ts:2`). +const GEOHASH_BASE32: &str = "0123456789bcdefghjkmnpqrstuvwxyz"; + +/// The largest integer JavaScript represents exactly (`Number.MAX_SAFE_INTEGER`). +const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991; + +/// The exclusive upper bound of `createdAtSchema` (`Math.log10(value) < 10`). +const CREATED_AT_LIMIT: i64 = 10_000_000_000; + +/// Kind 7, `EventKinds.REACTION`. +const REACTION_KIND: Kind = Kind(7); + +/// Kind 17, `EventKinds.EXTERNAL_CONTENT_REACTION`. +const EXTERNAL_CONTENT_REACTION_KIND: Kind = Kind(17); + +/// Kind 10002, `EventKinds.RELAY_LIST`. +const RELAY_LIST_KIND: Kind = Kind(10_002); + +/// Validates an event against the base schema (`eventSchema.parse`, `src/schemas/event-schema.ts`). /// -/// Phase 1 stub — job 005; will check the base schema before an event reaches storage; -/// see `src/utils/validation.ts`. -pub fn validate_event(_event: &Event) -> Result<(), CoreError> { - Err(CoreError::NotImplemented { - module: "core::validation::validate_event", - }) +/// The hex fields' length and alphabet are carried by [`EventId`](crate::event::EventId), +/// [`Pubkey`] and [`Signature`], so what is left is `created_at`, the tag tuples, and the +/// kind-7/17/10002/geohash refinements. +pub fn validate_event(event: &Event) -> Result<(), CoreError> { + check_event_shape(event).map_err(CoreError::InvalidEvent) +} + +/// The conformance checks `validate_event` applies, reporting the schema's message. +fn check_event_shape(event: &Event) -> Result<(), String> { + if event.created_at < 0 { + return Err("Number must be greater than or equal to 0".to_owned()); + } + + if event.created_at > MAX_SAFE_INTEGER || event.created_at >= CREATED_AT_LIMIT { + return Err("Invalid timestamp".to_owned()); + } + + for tag in &event.tags { + check_tag(tag)?; + } + + check_refinements(event) +} + +fn check_tag(tag: &Tag) -> Result<(), String> { + if tag.is_empty() { + return Err("Array must contain at least 1 element(s)".to_owned()); + } + + if tag[0].is_empty() { + return Err("String must contain at least 1 character(s)".to_owned()); + } + + Ok(()) +} + +/// The `superRefine` block of `eventSchema` (`src/schemas/event-schema.ts:42-94`), in the +/// same order: kind 7, else kind 17, else the relay-list rule, then the geohash rule for +/// every event. +fn check_refinements(event: &Event) -> Result<(), String> { + if event.kind == REACTION_KIND { + let has_target = event.tags.iter().any(|tag| { + matches!(tag.first().map(String::as_str), Some("e" | "a")) + && tag.get(1).is_some_and(|value| !value.is_empty()) + }); + + if !has_target { + return Err("Reaction event (kind 7) must have at least one e or a tag".to_owned()); + } + } else if event.kind == EXTERNAL_CONTENT_REACTION_KIND { + let has_kind_tag = event.tags.iter().any(|tag| { + tag.first().map(String::as_str) == Some("k") + && tag.get(1).is_some_and(|v| !v.is_empty()) + }); + let has_index_tag = event.tags.iter().any(|tag| { + tag.first().map(String::as_str) == Some("i") + && tag.get(1).is_some_and(|v| !v.is_empty()) + }); + + if !has_kind_tag || !has_index_tag { + return Err( + "External content reaction event (kind 17) must have k and i tags".to_owned(), + ); + } + } else if event.kind == RELAY_LIST_KIND { + for tag in &event.tags { + if tag.first().map(String::as_str) == Some("r") + && !tag.get(1).is_some_and(|url| is_valid_url(url)) + { + return Err("Invalid relay URL".to_owned()); + } + } + } + + for tag in &event.tags { + if tag.first().map(String::as_str) == Some("g") + && !tag.get(1).is_some_and(|value| is_geohash(value)) + { + return Err("Invalid geohash".to_owned()); + } + } + + Ok(()) +} + +/// `GEOHASH_PATTERN`: one or more base32 characters (`src/utils/geohash.ts:6`). +fn is_geohash(value: &str) -> bool { + !value.is_empty() + && value + .chars() + .all(|character| GEOHASH_BASE32.contains(character)) +} + +/// `z.string().url()`, which is WHATWG `new URL(value)` behind a try/catch: an absolute URL +/// with a scheme and a non-empty host. +fn is_valid_url(value: &str) -> bool { + match url::Url::parse(value) { + Ok(url) => url.host_str().is_some_and(|host| !host.is_empty()), + Err(_) => false, + } } diff --git a/crates/nostream-core/tests/wire_codec.rs b/crates/nostream-core/tests/wire_codec.rs new file mode 100644 index 000000000..e4e6980c3 --- /dev/null +++ b/crates/nostream-core/tests/wire_codec.rs @@ -0,0 +1,485 @@ +//! Parity with `test/unit/utils/messages.spec.ts` (14 cases), the codec half of +//! `test/unit/schemas/message-schema.spec.ts` (17), `test/unit/schemas/event-schema.spec.ts` +//! (57) and `test/unit/utils/filter.spec.ts` (9), all measured green at the capture commit +//! `242a3001`. +//! +//! | mocha case | Rust test | +//! |---|---| +//! | `createNotice returns a notice message` | `notice_frame_` | +//! | `createOutgoingEventMessage …` | `event_frame_` | +//! | `createEndOfStoredEventsNoticeMessage …` | `eose_frame_` | +//! | `createCommandResult …` (4) | `ok_frame_` | +//! | `createClosedMessage …` (2) | `closed_frame_` | +//! | `createSubscriptionMessage …` (3) | `decode_accepts_the_five_client_kinds` | +//! | `createRelayedEventMessage …` (2) | `relayed_event_` | +//! | `message-schema validateSchema …` (17) | `decode_…` | +//! | `event-schema …` (57) | `strict_event_schema_…` | +//! | `filter countFilterValues …` (5) | `filter_helpers_…` | +//! | `filter isGenericTagQuery …` (4) | `filter_helpers_…` | + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use nostream_core::event::{Event, EventId, Kind, Pubkey, Signature}; +use nostream_core::filter::{Filter, count_filter_values, is_generic_tag_query}; +use nostream_core::messages::{ + ClientMessage, ClosedMessage, CommandResult, CountResultMessage, EoseMessage, MessageType, + NoticeMessage, OutgoingEventMessage, RelayMessage, WireError, decode, +}; +use nostream_core::validation::validate_event; +use serde_json::json; + +const ID: &str = "b1601d26958e6508b7b9df0af609c652346c09392b6534d93aead9819a51b4ef"; +const PUBKEY: &str = "22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793"; +const SIG: &str = "ec8b2bc640c8c7e92fbc0e0a6f539da2635068a99809186f15106174d727456132977c78f3371d0ab01c108173df75750f33d8e04c4d7980bbb3fb70ba1e3848"; + +fn event() -> Event { + Event { + id: ID.parse().unwrap(), + pubkey: PUBKEY.parse().unwrap(), + created_at: 1_648_339_664, + kind: Kind::TEXT_NOTE, + tags: Vec::new(), + content: "learning terraform rn!".to_owned(), + sig: SIG.parse().unwrap(), + } +} + +fn frame_document(event: &Event) -> String { + serde_json::to_string(event).unwrap() +} + +#[test] +fn notice_frame_is_a_two_element_array() { + let frame = RelayMessage::Notice(NoticeMessage { + message: "some notice".to_owned(), + }); + + assert_eq!(frame.to_text(), r#"["NOTICE","some notice"]"#); +} + +#[test] +fn event_frame_is_the_verb_the_id_and_the_event_in_wire_order() { + let event = event(); + let frame = RelayMessage::Event(OutgoingEventMessage { + subscription_id: "subscriptionId".to_owned(), + event: event.clone(), + }); + + assert_eq!( + frame.to_text(), + format!(r#"["EVENT","subscriptionId",{}]"#, frame_document(&event)) + ); + assert!(frame.to_text().starts_with(&format!( + r#"["EVENT","subscriptionId",{{"id":"{ID}","pubkey":"{PUBKEY}","created_at":"# + ))); +} + +#[test] +fn eose_frame_is_the_verb_and_the_subscription() { + assert_eq!( + RelayMessage::Eose(EoseMessage { + subscription_id: "subscriptionId".to_owned() + }) + .to_text(), + r#"["EOSE","subscriptionId"]"# + ); +} + +#[test] +fn ok_frame_has_four_elements_without_params_and_five_with_them() { + let plain = RelayMessage::Ok(CommandResult { + event_id: ID.parse().unwrap(), + accepted: true, + message: "accepted".to_owned(), + params: None, + }); + + assert_eq!(plain.to_text(), format!(r#"["OK","{ID}",true,"accepted"]"#)); + + let with_reason = RelayMessage::Ok(CommandResult { + event_id: ID.parse().unwrap(), + accepted: false, + message: "blocked: content not allowed".to_owned(), + params: None, + }); + + assert_eq!( + with_reason.to_text(), + format!(r#"["OK","{ID}",false,"blocked: content not allowed"]"#) + ); + + let with_params = RelayMessage::Ok(CommandResult { + event_id: ID.parse().unwrap(), + accepted: false, + message: "rate-limited: slow down".to_owned(), + params: Some("1000".to_owned()), + }); + + assert_eq!( + with_params.to_text(), + format!(r#"["OK","{ID}",false,"rate-limited: slow down","1000"]"#) + ); + + let empty = RelayMessage::Ok(CommandResult { + event_id: ID.parse().unwrap(), + accepted: true, + message: String::new(), + params: None, + }); + + assert_eq!(empty.to_text(), format!(r#"["OK","{ID}",true,""]"#)); +} + +#[test] +fn closed_frame_has_three_elements_without_params_and_four_with_them() { + let plain = RelayMessage::Closed(ClosedMessage { + subscription_id: "sub1".to_owned(), + message: "error: shutting down".to_owned(), + params: None, + }); + + assert_eq!( + plain.to_text(), + r#"["CLOSED","sub1","error: shutting down"]"# + ); + + let with_params = RelayMessage::Closed(ClosedMessage { + subscription_id: "sub1".to_owned(), + message: "rate-limited: slow down".to_owned(), + params: Some("2500".to_owned()), + }); + + assert_eq!( + with_params.to_text(), + r#"["CLOSED","sub1","rate-limited: slow down","2500"]"# + ); +} + +#[test] +fn count_frame_carries_the_count_object() { + assert_eq!( + RelayMessage::Count(CountResultMessage { + subscription_id: "c1".to_owned(), + count: 0, + }) + .to_text(), + r#"["COUNT","c1",{"count":0}]"# + ); +} + +#[test] +fn auth_challenge_frame_is_the_verb_and_the_challenge() { + assert_eq!( + RelayMessage::Auth(nostream_core::messages::AuthChallengeMessage { + challenge: "abc".to_owned(), + }) + .to_text(), + r#"["AUTH","abc"]"# + ); +} + +#[test] +fn escape_sequences_match_json_stringify() { + let mut event = event(); + event.content = "a\u{1}b\u{1f}c\"d\\e\u{7f}f\u{2028}g😀h\n\t".to_owned(); + event.tags = vec![vec!["e".to_owned(), "AA".to_owned()]]; + + let text = serde_json::to_string(&event).unwrap(); + + // V8 leaves DEL (U+007F) and U+2028 raw, and refuses to escape a valid surrogate + // pair; serde_json does the same, which is what makes these bytes comparable. + assert!(text.contains("\"content\":\"a\\u0001b\\u001fc\\\"d\\\\e\u{7f}f\u{2028}g😀h\\n\\t\"")); + assert!(text.contains(r#""tags":[["e","AA"]]"#)); +} + +#[test] +fn relayed_event_message_is_unused_by_this_job_but_keeps_its_shape() { + let relayed = nostream_core::messages::RelayedEvent { + event: event(), + source: PUBKEY.parse().unwrap(), + }; + + assert_eq!(relayed.event.id, ID.parse::().unwrap()); +} + +#[test] +fn decode_accepts_the_five_client_kinds() { + let event = event(); + let event_document = frame_document(&event); + + let frames = [ + format!(r#"["EVENT",{event_document}]"#), + r#"["REQ","sub1",{"kinds":[1]}]"#.to_owned(), + r#"["REQ","sub1",{"kinds":[1]},{"kinds":[0],"authors":["aaaa"]}]"#.to_owned(), + r#"["CLOSE","sub1"]"#.to_owned(), + r#"["COUNT","c1",{"kinds":[1]}]"#.to_owned(), + format!(r#"["AUTH",{event_document}]"#), + ]; + + let decoded: Vec = frames.iter().map(|text| decode(text).unwrap()).collect(); + + assert_eq!( + decoded + .iter() + .map(ClientMessage::message_type) + .collect::>(), + vec![ + MessageType::Event, + MessageType::Req, + MessageType::Req, + MessageType::Close, + MessageType::Count, + MessageType::Auth, + ] + ); + + assert_eq!(decoded[0], ClientMessage::Event(event.clone())); + assert_eq!( + decoded[5], + ClientMessage::Auth(nostream_core::messages::AuthMessage { event }) + ); +} + +#[test] +fn decode_reports_the_capture_notice_strings() { + for (frame, detail) in [ + (r#"["NEG-OPEN","sub1",{}]"#, "Invalid input"), + (r#"["FOO"]"#, "Invalid input"), + (r#"{"kind":1}"#, "Invalid input"), + ( + r#"["REQ","s1"]"#, + "REQ message must contain at least one filter", + ), + ( + r#"["COUNT","c1"]"#, + "COUNT message must contain at least one filter", + ), + ( + r#"["REQ","s1",{"kinds":[1]},{"kinds":[1]},{"kinds":[1]},{"kinds":[1]},{"kinds":[1]},{"kinds":[1]},{"kinds":[1]},{"kinds":[1]},{"kinds":[1]},{"kinds":[1]},{"kinds":[1]}]"#, + "REQ message must contain at most 12 elements", + ), + ( + r#"["COUNT","c1",{"kinds":[1]},{"kinds":[1]},{"kinds":[1]},{"kinds":[1]},{"kinds":[1]},{"kinds":[1]},{"kinds":[1]},{"kinds":[1]},{"kinds":[1]},{"kinds":[1]},{"kinds":[1]}]"#, + "COUNT message must contain at most 12 elements", + ), + ( + r#"["REQ","s1",{"unknown":1}]"#, + "Expected array, received number", + ), + (r#"["REQ","s1",{"unknown":["x"]}]"#, "Unknown key: unknown"), + ( + r#"["REQ","s1",{"kinds":[[1,2]]}]"#, + "Expected number, received array", + ), + ] { + let error = decode(frame).unwrap_err(); + + assert_eq!(error, WireError::Validation(detail.to_owned()), "{frame}"); + assert_eq!(error.notice(), format!("invalid: {detail}")); + } +} + +#[test] +fn decode_reports_json_syntax_failure_as_a_tolerated_detail() { + let error = decode("{\"kind\":").unwrap_err(); + + assert!(matches!(error, WireError::Json(_)), "{error:?}"); + assert!(error.notice().starts_with("invalid: ")); +} + +#[test] +fn decode_validates_the_event_payload() { + let mut document = serde_json::to_value(event()).unwrap(); + + document["extra"] = json!(true); + + let error = decode(&format!(r#"["EVENT",{document}]"#)).unwrap_err(); + + assert_eq!( + error, + WireError::Validation("Unrecognized key(s) in object: 'extra'".to_owned()) + ); + + let frame = format!(r#"["AUTH",{}]"#, frame_document(&event())); + + assert!(matches!(decode(&frame), Ok(ClientMessage::Auth(_)))); +} + +#[test] +fn strict_event_schema_rejects_an_unknown_field() { + let document = format!( + r#"{{"id":"{ID}","pubkey":"{PUBKEY}","created_at":1,"kind":1,"tags":[],"content":"","sig":"{SIG}","extra":true}}"# + ); + + let error = decode(&format!(r#"["EVENT",{document}]"#)).unwrap_err(); + + assert!( + error.detail().contains("Unrecognized key"), + "{}", + error.detail() + ); +} + +#[test] +fn strict_event_schema_checks_the_refinements() { + // NIP-25: a kind-7 reaction needs an `e` or `a` tag. + let mut reaction = event(); + reaction.kind = Kind(7); + assert_eq!( + validate_event(&reaction).unwrap_err().to_string(), + "invalid event: Reaction event (kind 7) must have at least one e or a tag" + ); + reaction.tags = vec![vec!["e".to_owned(), ID.to_owned()]]; + assert!(validate_event(&reaction).is_ok()); + + // NIP-25: a kind-17 external reaction needs both a `k` and an `i` tag. + let mut external = event(); + external.kind = Kind(17); + external.tags = vec![vec!["k".to_owned(), "1".to_owned()]]; + assert_eq!( + validate_event(&external).unwrap_err().to_string(), + "invalid event: External content reaction event (kind 17) must have k and i tags" + ); + external + .tags + .push(vec!["i".to_owned(), "https://example.com".to_owned()]); + assert!(validate_event(&external).is_ok()); + + // NIP-65: every `r` tag on a relay list is a URL. + let mut relay_list = event(); + relay_list.kind = Kind(10_002); + relay_list.tags = vec![vec!["r".to_owned(), "not a url".to_owned()]]; + assert_eq!( + validate_event(&relay_list).unwrap_err().to_string(), + "invalid event: Invalid relay URL" + ); + relay_list.tags = vec![vec!["r".to_owned(), "wss://relay.example.com".to_owned()]]; + assert!(validate_event(&relay_list).is_ok()); + + // NIP-12: every `g` tag is a geohash. + let mut located = event(); + located.tags = vec![vec!["g".to_owned(), "u4pruydqqvj".to_owned()]]; + assert!(validate_event(&located).is_ok()); + located.tags = vec![vec!["g".to_owned(), "abc".to_owned()]]; + assert_eq!( + validate_event(&located).unwrap_err().to_string(), + "invalid event: Invalid geohash" + ); +} + +#[test] +fn strict_event_schema_checks_the_timestamp_window() { + let mut event = event(); + + event.created_at = -1; + assert_eq!( + validate_event(&event).unwrap_err().to_string(), + "invalid event: Number must be greater than or equal to 0" + ); + + // 1672295751103 is a millisecond timestamp: `Math.log10(value) < 10` fails. + event.created_at = 1_672_295_751_103; + assert_eq!( + validate_event(&event).unwrap_err().to_string(), + "invalid event: Invalid timestamp" + ); + + event.created_at = 10_000_000_000; + assert_eq!( + validate_event(&event).unwrap_err().to_string(), + "invalid event: Invalid timestamp" + ); + + event.created_at = 1_660_306_803; + assert!(validate_event(&event).is_ok()); +} + +#[test] +fn strict_event_schema_checks_tag_tuples() { + let mut event = event(); + + event.tags = vec![Vec::new()]; + assert_eq!( + validate_event(&event).unwrap_err().to_string(), + "invalid event: Array must contain at least 1 element(s)" + ); + + event.tags = vec![vec![String::new(), "x".to_owned()]]; + assert_eq!( + validate_event(&event).unwrap_err().to_string(), + "invalid event: String must contain at least 1 character(s)" + ); + + event.tags = vec![vec!["e".to_owned()]]; + assert!(validate_event(&event).is_ok()); +} + +#[test] +fn strict_event_schema_accepts_the_capture_fixture_event() { + // `test/unit/schemas/event-schema.spec.ts`'s `beforeEach` event: kind 7 with `e`/`p` tags. + let event: Event = serde_json::from_str( + r#"{"id":"fa4dd948576fe182f5d0e3120b9df42c83dffa1c884754d5e4d3b0a2f98a01c5","pubkey":"edfa27d49d2af37ee331e1225bb6ed1912c6d999281b36d8018ad99bc3573c29","created_at":1660306803,"kind":7,"tags":[["e","c58e83bb744e4c29642db7a5c3bd1519516ad5c51f6ba5f90c451d03c1961210","","root"],["p","edfa27d49d2af37ee331e1225bb6ed1912c6d999281b36d8018ad99bc3573c29"]],"content":"","sig":"313a9b8cd68267a51da84e292c0937d1f3686c6757c4584f50fcedad2b13fad755e6226924f79880fb5aa9de95c04231a4823981513ac9e7092bad7488282a96"}"#, + ) + .unwrap(); + + assert!(validate_event(&event).is_ok()); +} + +#[test] +fn filter_helpers_match_the_capture() { + assert!(is_generic_tag_query("#a")); + assert!(is_generic_tag_query("#A")); + assert!(!is_generic_tag_query("#0")); + assert!(!is_generic_tag_query("#abc")); + + let count = + |document: &str| count_filter_values(&serde_json::from_str::(document).unwrap()); + + assert_eq!(count("{}"), 0); + assert_eq!( + count(r#"{"ids":["aa"],"authors":["bb","cc"],"kinds":[1,2,3]}"#), + 6 + ); + assert_eq!(count(r##"{"#e":["aa","bb"],"#p":["cc"]}"##), 3); + assert_eq!( + count(r#"{"ids":["aa"],"since":1,"until":2,"limit":3,"search":"aa bb cc"}"#), + 1 + ); + assert_eq!(count(r#"{"ids":[],"authors":["aa"]}"#), 1); +} + +#[test] +fn filter_validation_reports_the_first_issue_message() { + let error = decode(r#"["REQ","s1",{"limit":-1}]"#).unwrap_err(); + + assert_eq!( + error, + WireError::Validation("Number must be greater than or equal to 0".to_owned()) + ); +} + +#[test] +fn a_frame_that_is_not_an_array_is_invalid_input() { + let error = decode("42").unwrap_err(); + + assert_eq!(error, WireError::Validation("Invalid input".to_owned())); +} + +#[test] +fn signature_and_pubkey_hex_rules_are_reported() { + let mut document = serde_json::to_value(event()).unwrap(); + document["id"] = json!("F".repeat(64)); + + let error = decode(&format!(r#"["EVENT",{document}]"#)).unwrap_err(); + assert_eq!(error, WireError::Validation("Invalid".to_owned())); + + document["id"] = json!("f".repeat(63)); + let error = decode(&format!(r#"["EVENT",{document}]"#)).unwrap_err(); + assert_eq!( + error, + WireError::Validation("String must contain exactly 64 character(s)".to_owned()) + ); + + let _: Pubkey = PUBKEY.parse().unwrap(); + let _: Signature = SIG.parse().unwrap(); +} From 09afda627f2aee3dab0007a417dbc3ffa29b7e84 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Fri, 23 Oct 2026 14:55:30 -0400 Subject: [PATCH 02/20] feat(relay): port the handlers, transport, limiters and live dispatch --- crates/nostream-relay/Cargo.toml | 12 + crates/nostream-relay/src/connection.rs | 296 +++- crates/nostream-relay/src/error.rs | 26 +- crates/nostream-relay/src/handlers/auth.rs | 38 +- crates/nostream-relay/src/handlers/count.rs | 107 +- crates/nostream-relay/src/handlers/event.rs | 626 ++++++- crates/nostream-relay/src/handlers/mod.rs | 190 ++- .../nostream-relay/src/handlers/subscribe.rs | 366 +++- .../src/handlers/unsubscribe.rs | 16 +- crates/nostream-relay/src/hidden_content.rs | 166 ++ crates/nostream-relay/src/lib.rs | 38 +- crates/nostream-relay/src/limits.rs | 512 +++++- crates/nostream-relay/src/relay.rs | 532 ++++++ crates/nostream-relay/src/relay_key.rs | 121 ++ crates/nostream-relay/src/sink.rs | 53 + .../src/strategies/contact_list.rs | 6 +- .../nostream-relay/src/strategies/default.rs | 49 +- .../nostream-relay/src/strategies/delete.rs | 6 +- .../src/strategies/dvm_job_request.rs | 6 +- .../src/strategies/ephemeral.rs | 6 +- .../src/strategies/gift_wrap.rs | 6 +- crates/nostream-relay/src/strategies/group.rs | 6 +- .../src/strategies/invite_request.rs | 6 +- .../src/strategies/join_request.rs | 6 +- .../src/strategies/leave_request.rs | 6 +- crates/nostream-relay/src/strategies/mod.rs | 264 ++- .../strategies/parameterized_replaceable.rs | 6 +- .../src/strategies/replaceable.rs | 6 +- .../nostream-relay/src/strategies/report.rs | 6 +- .../src/strategies/timestamp.rs | 6 +- .../nostream-relay/src/strategies/vanish.rs | 6 +- crates/nostream-relay/src/testing.rs | 980 +++++++++++ crates/nostream-relay/src/transport.rs | 173 ++ crates/nostream-relay/src/unavailable.rs | 274 +++ crates/nostream-relay/tests/common/mod.rs | 261 +++ crates/nostream-relay/tests/connection.rs | 433 +++++ crates/nostream-relay/tests/handlers.rs | 1469 +++++++++++++++++ crates/nostream-relay/tests/hidden_content.rs | 219 +++ crates/nostream-relay/tests/limits.rs | 754 +++++++++ crates/nostream-relay/tests/transport.rs | 573 +++++++ 40 files changed, 8471 insertions(+), 161 deletions(-) create mode 100644 crates/nostream-relay/src/hidden_content.rs create mode 100644 crates/nostream-relay/src/relay.rs create mode 100644 crates/nostream-relay/src/relay_key.rs create mode 100644 crates/nostream-relay/src/sink.rs create mode 100644 crates/nostream-relay/src/testing.rs create mode 100644 crates/nostream-relay/src/transport.rs create mode 100644 crates/nostream-relay/src/unavailable.rs create mode 100644 crates/nostream-relay/tests/common/mod.rs create mode 100644 crates/nostream-relay/tests/connection.rs create mode 100644 crates/nostream-relay/tests/handlers.rs create mode 100644 crates/nostream-relay/tests/hidden_content.rs create mode 100644 crates/nostream-relay/tests/limits.rs create mode 100644 crates/nostream-relay/tests/transport.rs diff --git a/crates/nostream-relay/Cargo.toml b/crates/nostream-relay/Cargo.toml index 146b28a20..fa18dff0c 100644 --- a/crates/nostream-relay/Cargo.toml +++ b/crates/nostream-relay/Cargo.toml @@ -8,10 +8,22 @@ repository.workspace = true description = "Nostr wire handlers, event strategies, connection state and rate limiters" [dependencies] +chrono.workspace = true +futures-util.workspace = true +hex.workspace = true +hmac.workspace = true nostream-config.workspace = true nostream-core.workspace = true nostream-storage.workspace = true +serde_json.workspace = true +sha2.workspace = true thiserror.workspace = true +tokio.workspace = true +tokio-tungstenite.workspace = true +tracing.workspace = true + +[dev-dependencies] +hex.workspace = true [lints] workspace = true diff --git a/crates/nostream-relay/src/connection.rs b/crates/nostream-relay/src/connection.rs index 5ded1f745..ed37909f5 100644 --- a/crates/nostream-relay/src/connection.rs +++ b/crates/nostream-relay/src/connection.rs @@ -1,11 +1,27 @@ -//! Per-connection state. Mirrors `src/@types/{subscription,clients}.ts` and the auth/session -//! half of `src/utils/nip42-session.ts`. +//! Per-connection state: the subscription table, the NIP-42 session, the in-flight reads +//! and the socket's identity. Mirrors `src/@types/{subscription,clients}.ts`, the +//! subscription half of `src/adapters/web-socket-adapter.ts`, and +//! `src/utils/nip42-session.ts`. +//! +//! One task per socket owns the transport; the [`Connection`] is shared with the handlers +//! that task runs, because a `CLOSE` and a live dispatch both need the table while a read +//! is in flight (`web-socket-adapter.ts:312-335`). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; -use nostream_core::{Filter, Pubkey}; +use tokio::sync::watch; + +use nostream_config::settings::Settings; +use nostream_core::clock::Clock; +use nostream_core::nip42::Nip42Session; +use nostream_core::{Event, Filter, Pubkey}; use crate::error::RelayError; +use crate::sink::FrameSink; + +/// The socket the acceptor hands over: a negotiated websocket over the accepted TCP stream. +pub type AcceptedTransport = tokio_tungstenite::WebSocketStream; /// One registered subscription (`src/@types/subscription.ts`). #[derive(Debug, Clone, PartialEq, Eq)] @@ -14,46 +30,257 @@ pub struct Subscription { pub filters: Vec, } -/// A connected client and what it may read (`src/@types/clients.ts`). -#[derive(Debug, Clone, Default)] +/// A connected client: what it subscribed to, who it authenticated as, and which reads are +/// running. +#[derive(Debug)] pub struct Connection { - pub pubkey: Option, - pub subscriptions: HashMap, - pub is_authenticated: bool, + /// `` in every rate-limit bucket key (`A11`: the TCP peer). + pub remote_address: String, + subscriptions: Mutex>, + session: Mutex, + in_flight: Mutex>>, } impl Connection { - /// Accepts one upgraded socket into the relay's connection table. - /// - /// Phase 1 stub — job 005; will register the socket, apply connection limits and send the - /// NIP-42 challenge; see `src/adapters/web-socket-adapter.ts`. The accepted transport and - /// the shared handler context arrive together because registration, limits and the - /// challenge all read the context — a bare address cannot be registered, limited or - /// challenged, and Phase 2's transport type is decided with the WebSocket dependency. - pub async fn accept( - _transport: crate::connection::AcceptedTransport, - _context: crate::handlers::HandlerContext, + /// Accepts one upgraded socket: mints this socket's NIP-42 challenge, sends it, and + /// returns the state (`WebSocketAdapter`'s constructor, `:110-123`). + pub fn accept( + transport: &AcceptedTransport, + context: &ConnectionContext, ) -> Result { - Err(RelayError::NotImplemented { - module: "relay::connection::Connection::accept", + let remote_address = match transport.get_ref().peer_addr() { + Ok(address) => address.ip().to_string(), + Err(error) => { + tracing::debug!(error = %error, "accepted socket has no peer address"); + String::new() + } + }; + + let ttl_seconds = context + .settings + .nip42 + .as_ref() + .and_then(|nipp42| nipp42.session_expiry_seconds) + .unwrap_or(0); + + let session = Nip42Session::new(ttl_seconds) + .map_err(|error| RelayError::Configuration(error.to_string()))?; + + context + .sink + .send(&nostream_core::messages::RelayMessage::Auth( + nostream_core::messages::AuthChallengeMessage { + challenge: session.challenge().to_owned(), + }, + )); + + Ok(Self::new(remote_address, session)) + } + + /// The per-socket state, with no socket: the shape [`Connection::accept`] builds and the + /// one tests build directly. + pub fn new(remote_address: String, session: Nip42Session) -> Self { + Self { + remote_address, + subscriptions: Mutex::new(HashMap::new()), + session: Mutex::new(session), + in_flight: Mutex::new(HashMap::new()), + } + } + + /// Registers or replaces one subscription (`onSubscribed`). + pub fn subscribe(&self, subscription: Subscription) { + self.subscriptions + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(subscription.id.clone(), subscription); + } + + /// Removes one subscription (`onUnsubscribed`), returning it when it existed. + pub fn unsubscribe(&self, id: &str) -> Option { + self.subscriptions + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(id) + } + + /// Every registered subscription, in id order, which is the order a drain writes its + /// `CLOSED` frames in. + pub fn subscriptions(&self) -> Vec { + let mut subscriptions: Vec = self + .subscriptions + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .values() + .cloned() + .collect(); + + subscriptions.sort_by(|left, right| left.id.cmp(&right.id)); + + subscriptions + } + + /// How many subscriptions are registered. + pub fn subscription_count(&self) -> usize { + self.subscriptions + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .len() + } + + /// The registered filters for one id. + pub fn subscription(&self, id: &str) -> Option { + self.subscriptions + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(id) + .cloned() + } + + /// Whether `filters` is exactly the filter set registered under `id` + /// (`canSubscribe`'s duplicate check). + pub fn has_identical_subscription(&self, id: &str, filters: &[Filter]) -> bool { + self.subscription(id).is_some_and(|subscription| { + !subscription.filters.is_empty() && subscription.filters == filters }) } - /// Registers or replaces one subscription. + /// The subscription ids whose filters match `event`, in id order (`onSendEvent`). + pub fn matching_subscription_ids(&self, event: &Event) -> Vec { + let mut matches: Vec = self + .subscriptions + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .values() + .filter(|subscription| { + subscription + .filters + .iter() + .any(|filter| filter.matches(event)) + }) + .map(|subscription| subscription.id.clone()) + .collect(); + + matches.sort(); + + matches + } + + /// This socket's NIP-42 challenge (`getChallenge`). + pub fn challenge(&self) -> String { + self.session + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .challenge() + .to_owned() + } + + /// The live authenticated pubkeys (`getAuthenticatedPubkeys`). + pub fn authenticated_pubkeys(&self, now: i64) -> HashSet { + self.session + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .authenticated_pubkeys(now) + } + + /// The live authenticated pubkeys in stable order (`[...getAuthenticatedPubkeys()]`). + pub fn authenticated_pubkeys_in_order(&self, now: i64) -> Vec { + self.session + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .authenticated_pubkeys_in_order(now) + } + + /// Records a successful AUTH, `false` on replay (`addAuthenticatedPubkey`). + pub fn authenticate( + &self, + pubkey: Pubkey, + auth_event_id: nostream_core::EventId, + now: i64, + ) -> bool { + self.session + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .authenticate(pubkey, auth_event_id, now) + } + + /// Starts an abortable read for `subscription_id` and returns its cancellation handle. /// - /// Phase 1 stub — job 005; will enforce the client subscription limits; see `src/utils/filter.ts`. - pub fn subscribe(&mut self, _subscription: Subscription) -> Result<(), RelayError> { - Err(RelayError::NotImplemented { - module: "relay::connection::Connection::subscribe", - }) + /// A previous read for the same id is aborted first, so a re-REQ cannot leave two + /// streams writing to one subscription (`onClientMessage` replaces the handler). + pub fn begin_read(&self, subscription_id: &str) -> watch::Receiver { + let (sender, receiver) = watch::channel(false); + let mut in_flight = self + .in_flight + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + if let Some(previous) = in_flight.insert(subscription_id.to_owned(), sender) { + let _ = previous.send(true); + } + + receiver + } + + /// Ends a read without aborting it (the stream finished). + pub fn end_read(&self, subscription_id: &str) { + self.in_flight + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(subscription_id); + } + + /// Aborts the read running for `subscription_id`, if any (`abortInFlightSubscriptionReads`). + pub fn abort_read(&self, subscription_id: &str) { + let mut in_flight = self + .in_flight + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + if let Some(sender) = in_flight.remove(subscription_id) { + let _ = sender.send(true); + } + } + + /// Whether a read is running for `subscription_id`. + pub fn read_in_flight(&self, subscription_id: &str) -> bool { + self.in_flight + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .contains_key(subscription_id) + } + + /// Connection close (`onClientClose`): clears the subscription table and the auth + /// session, and aborts every in-flight read. + pub fn close(&self) { + self.subscriptions + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clear(); + + self.session + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clear(); + + let mut in_flight = self + .in_flight + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + for (_, sender) in in_flight.drain() { + let _ = sender.send(true); + } } } -/// The transport the acceptor hands over. Phase 1 pins the seam, not the socket type: the -/// WebSocket dependency (and with it the real transport) is a Phase-2 decision, so this is -/// an opaque placeholder the stub signatures can name. -#[derive(Debug, Clone, Default)] -pub struct AcceptedTransport; +/// The state a connection is accepted with: the settings it reads through, the clock, and +/// the sink its challenge leaves through. +pub struct ConnectionContext { + pub settings: Arc, + pub clock: Arc, + pub sink: FrameSink, +} /// One negentropy session (oracle `041fe34e`; no session file exists at this branch's capture). #[derive(Debug, Clone, Default)] @@ -64,7 +291,10 @@ pub struct NegentropySession { /// Accepts an open negentropy session for a connection. /// -/// Phase 1 stub — job 005; will track the set-reconciliation state; see `src/utils/negentropy-sessions.ts`. +/// Phase 2 stub — job 011; will track the set-reconciliation state; the capture commit this +/// branch ports from has no NIP-77 handler (`src/utils/negentropy-sessions.ts` first appears +/// at `041fe34e`), so a NIP-77 job must be cut against that tree; see +/// `src/utils/negentropy-sessions.ts`. pub fn open_negentropy_session(_session: NegentropySession) -> Result<(), RelayError> { Err(RelayError::NotImplemented { module: "relay::connection::open_negentropy_session", diff --git a/crates/nostream-relay/src/error.rs b/crates/nostream-relay/src/error.rs index 98891bfe1..c5d59d539 100644 --- a/crates/nostream-relay/src/error.rs +++ b/crates/nostream-relay/src/error.rs @@ -1,12 +1,14 @@ //! The relay's error type. +use nostream_storage::StorageError; use thiserror::Error; /// Errors raised by `nostream-relay`. #[derive(Debug, Error)] pub enum RelayError { - /// A Phase 1 stub. The module names the call that is not implemented yet. - #[error("not implemented in Phase 1 (job 005): {module}")] + /// A stub. The module names the call that is not implemented yet and the label on the + /// item names the job that will fill it. + #[error("not implemented: {module}")] NotImplemented { module: &'static str }, /// A client message could not be decoded or is malformed. @@ -16,4 +18,24 @@ pub enum RelayError { /// The socket is closing under drain. #[error("the relay is draining and refuses new work")] Draining, + + /// A repository call failed. + #[error(transparent)] + Storage(#[from] StorageError), + + /// The rate limiter's in-process store could not be read or written. + #[error("rate limiter store unavailable: {0}")] + RateLimiter(String), + + /// Settings the relay needs are missing or malformed. + #[error("relay configuration: {0}")] + Configuration(String), + + /// The signed websocket handshake failed. + #[error("websocket handshake: {0}")] + Handshake(String), + + /// A frame could not be written to the socket. + #[error("websocket write: {0}")] + Transport(String), } diff --git a/crates/nostream-relay/src/handlers/auth.rs b/crates/nostream-relay/src/handlers/auth.rs index 78afa579d..193e4e17a 100644 --- a/crates/nostream-relay/src/handlers/auth.rs +++ b/crates/nostream-relay/src/handlers/auth.rs @@ -1,10 +1,34 @@ -//! The `auth-message-handler.ts` handler. +//! The `auth-message-handler.ts` handler (NIP-42). -use crate::error::RelayError; +use nostream_core::messages::AuthMessage; +use nostream_core::nip42; -/// Phase 1 stub — job 005; will handle a NIP-42 AUTH message; see `src/handlers/auth-message-handler.ts`. -pub async fn handle() -> Result<(), RelayError> { - Err(RelayError::NotImplemented { - module: "relay::handlers::auth", - }) +use crate::handlers::HandlerContext; + +/// The handler's entry point (`handleMessage`, `:22-70`). +/// +/// The seven outcomes are `nip42::authenticate`'s failures in the capture's order, then the +/// replay check, then `["OK",,true,""]`. The challenge is never rotated by AUTH +/// (`web-socket-adapter.ts:186-196`). +pub async fn handle(message: AuthMessage, context: &HandlerContext) { + let event = message.event; + let now = context.now_secs(); + let challenge = context.connection.challenge(); + let relay_url = context.relay_url().to_owned(); + + match nip42::authenticate(&event, &challenge, &relay_url, now) { + Ok(pubkey) => { + if !context.connection.authenticate(pubkey, event.id, now) { + context.send_ok(event.id, false, "invalid: auth event already used", None); + return; + } + + context.send_ok(event.id, true, "", None); + } + Err(failure) => { + let reason = format!("invalid: {}", failure.message()); + + context.send_ok(event.id, false, &reason, None); + } + } } diff --git a/crates/nostream-relay/src/handlers/count.rs b/crates/nostream-relay/src/handlers/count.rs index ce880ab1c..da9ccfefc 100644 --- a/crates/nostream-relay/src/handlers/count.rs +++ b/crates/nostream-relay/src/handlers/count.rs @@ -1,10 +1,103 @@ -//! The `count-message-handler.ts` handler. +//! The `count-message-handler.ts` handler (NIP-45). -use crate::error::RelayError; +use nostream_core::filter::{count_filter_values, dedupe_filters}; +use nostream_core::messages::CountResultMessage; +use nostream_core::nip42; +use nostream_core::{Filter, messages::CountMessage}; -/// Phase 1 stub — job 005; will handle a COUNT query; see `src/handlers/count-message-handler.ts`. -pub async fn handle() -> Result { - Err(RelayError::NotImplemented { - module: "relay::handlers::count", - }) +use crate::handlers::HandlerContext; + +/// The handler's entry point (`handleMessage`, `:19-50`). +pub async fn handle(message: CountMessage, context: &HandlerContext) { + let query_id = message.subscription_id; + let count_enabled = context + .settings + .nip45 + .as_ref() + .and_then(|nip45| nip45.enabled) + .unwrap_or(true); + + if !count_enabled { + context.send_closed(&query_id, "COUNT is disabled by relay configuration", None); + return; + } + + // Some clients send the same filter more than once. + let filters = dedupe_filters(message.filters); + + if let Some(reason) = can_count(&query_id, &filters, context) { + context.send_closed(&query_id, &reason, None); + return; + } + + match context.stores.events.count_by_filters(&filters).await { + Ok(count) => { + context + .sink + .send(&nostream_core::messages::RelayMessage::Count( + CountResultMessage { + subscription_id: query_id, + count, + }, + )); + } + Err(error) => { + tracing::debug!(error = %error, "count request failed"); + // Keep this message generic so internal errors are not leaked to clients. + context.send_closed(&query_id, "error: unable to count events", None); + } + } +} + +/// `canCount` (`:54-78`). +fn can_count(query_id: &str, filters: &[Filter], context: &HandlerContext) -> Option { + let limits = context + .settings + .limits + .as_ref() + .and_then(|limits| limits.client.as_ref()) + .and_then(|client| client.subscription.as_ref()); + + let max_filters = limits.and_then(|limits| limits.max_filters).unwrap_or(0); + + if max_filters > 0 && filters.len() as u64 > max_filters { + return Some(format!( + "Too many filters: Number of filters per count query must be less than or equal to {max_filters}" + )); + } + + let max_filter_values = limits + .and_then(|limits| limits.max_filter_values) + .unwrap_or(0); + + if max_filter_values > 0 + && filters + .iter() + .any(|filter| count_filter_values(filter) as u64 > max_filter_values) + { + return Some(format!( + "Too many filter values: Number of values per filter must be less than or equal to {max_filter_values}" + )); + } + + if let Some(max_id_length) = limits.and_then(|limits| limits.max_subscription_id_length) + && query_id.len() as u64 > max_id_length + { + return Some(format!( + "Query ID too long: Query ID must be less than or equal to {max_id_length}" + )); + } + + // NIP-42: restricted-kind counts must be scoped to the client's own pubkeys. + if !nip42::is_count_authorized( + &context.restricted_read_kinds(), + filters, + &context.authenticated_pubkeys(), + ) { + return Some( + "auth-required: authentication is required to count these event kinds".to_owned(), + ); + } + + None } diff --git a/crates/nostream-relay/src/handlers/event.rs b/crates/nostream-relay/src/handlers/event.rs index 2f0d948f0..ef7c47b9a 100644 --- a/crates/nostream-relay/src/handlers/event.rs +++ b/crates/nostream-relay/src/handlers/event.rs @@ -1,10 +1,626 @@ //! The `event-message-handler.ts` handler. +//! +//! The pipeline is the capture's, in the capture's order +//! (`src/handlers/event-message-handler.ts:66-167`), and every reason string is verbatim: +//! validity → expiry → event rate limit → acceptance gate → authentication → protected +//! events → request-to-vanish → admission → NIP-05 → adaptive-PoW sample → strategy dispatch. + +use nostream_config::settings::{ContentLimits, ContentLimitsOrVec, Settings}; +use nostream_core::adaptive_pow::{self, AdaptivePowConfig, WotPowThreshold}; +use nostream_core::constants::ALL_RELAYS; +use nostream_core::nip05; +use nostream_core::{Event, Kind, crypto, expiration, pow, time}; use crate::error::RelayError; +use crate::handlers::HandlerContext; +use crate::limits::kind_selector_matches; +use crate::strategies::{EventStrategy, Strategy}; + +/// `EventKinds.REACTION`. +const REACTION_KIND: Kind = Kind(7); + +/// `EventKinds.SET_METADATA`. +const SET_METADATA_KIND: Kind = Kind(0); + +/// `EventKinds.REPOST` and `EventKinds.GENERIC_REPOST`. +const REPOST_KINDS: [Kind; 2] = [Kind(6), Kind(16)]; + +/// `EventKinds.AUTH`. +const AUTH_KIND: Kind = Kind(22_242); + +/// `EventKinds.NIP43_JOIN_REQUEST`, `NIP43_INVITE_REQUEST`, `NIP43_LEAVE_REQUEST`. +const NIP43_REQUEST_KINDS: [Kind; 3] = [Kind(28_934), Kind(28_935), Kind(28_936)]; + +/// The handler's entry point (`handleMessage`). +pub async fn handle(event: Event, context: &HandlerContext) { + if let Some(reason) = is_event_valid(&event, context.relay_url()) { + context.send_ok(event.id, false, &reason, None); + return; + } + + let now = context.now_secs(); + + if expiration::is_expired_event(&event, now) { + context.send_ok(event.id, false, "event is expired", None); + return; + } + + // The capture resolves the relay key at the top of `isRateLimited`; without one the + // throw surfaces as this frame (measured; ADR 0004). + let relay_public_key = match context.keys.resolve(context.relay_url()) { + Ok(keys) => keys.public_key, + Err(error) => { + tracing::debug!(error = %error, "no relay signing key; refusing the event"); + context.send_ok(event.id, false, "error: unable to process event", None); + return; + } + }; + + match context.limits.event_backoff( + &event, + &context.connection.remote_address, + Some(&relay_public_key), + ) { + Ok(Some(backoff)) => { + context.send_ok( + event.id, + false, + "rate-limited: slow down", + Some(backoff.to_string()), + ); + return; + } + Ok(None) => {} + Err(error) => { + // Fail closed: an unavailable limiter must not leave the gate open. + tracing::warn!(error = %error, "rate limiter unavailable; refusing the event"); + context.send_ok(event.id, false, "rate-limited: slow down", None); + return; + } + } + + if let Some(reason) = can_accept_event(&event, context, now, &relay_public_key) { + context.send_ok(event.id, false, &reason, None); + return; + } + + if let Some(reason) = is_authentication_required(&event, context, &relay_public_key) { + context.send_ok(event.id, false, &reason, None); + return; + } + + if let Some(reason) = is_protected_event_blocked(&event, context).await { + context.send_ok(event.id, false, &reason, None); + return; + } + + if let Some(reason) = is_blocked_by_request_to_vanish(&event, context, &relay_public_key).await + { + context.send_ok(event.id, false, &reason, None); + return; + } + + if let Some(reason) = is_user_admitted(&event, context, &relay_public_key).await { + context.send_ok(event.id, false, &reason, None); + return; + } + + if let Some(reason) = check_nip05_verification(&event, context, &relay_public_key).await { + context.send_ok(event.id, false, &reason, None); + return; + } + + if let Some(pow_settings) = context + .settings + .limits + .as_ref() + .and_then(|limits| limits.event.as_ref()) + .and_then(|event| event.pow.as_ref()) + .filter(|pow| pow.enabled) + { + let mut state = context + .pow + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + state.record_event(pow_settings.period_ms, context.now_ms()); + } + + // The capture's `error: event not supported` branch is the factory returning nothing; + // the registry always selects an arm, so it has no occurrence here. + if let Err(error) = Strategy.execute(&event, context).await { + match &error { + RelayError::NotImplemented { module } => { + tracing::debug!(module, "strategy is not implemented in this job"); + } + other => tracing::error!(error = %other, "event strategy failed"), + } + + context.send_ok(event.id, false, "error: unable to process event", None); + } +} + +/// `isEventValid` (`:333-358`). +fn is_event_valid(event: &Event, relay_url: &str) -> Option { + match crypto::compute_event_id(event) { + Ok(id) if id == event.id => {} + _ => return Some("invalid: event id does not match".to_owned()), + } + + if crypto::verify_event_signature(event).is_err() { + return Some("invalid: event signature verification failed".to_owned()); + } + + if event.kind == Kind::REQUEST_TO_VANISH && !event.is_request_to_vanish(Some(relay_url)) { + return Some("invalid: request to vanish relay tag invalid".to_owned()); + } + + if matches!( + event.kind, + Kind::SEAL | Kind::DIRECT_MESSAGE | Kind::FILE_MESSAGE | Kind::MARMOT_WELCOME_RUMOR + ) { + return Some(format!( + "blocked: kind {} events must not be published directly; wrap them in a kind 1059 gift wrap", + event.kind + )); + } + + if event.kind == AUTH_KIND { + return Some("invalid: auth events must be sent using the AUTH message type".to_owned()); + } + + None +} + +/// `canAcceptEvent` (`:175-283`). +fn can_accept_event( + event: &Event, + context: &HandlerContext, + now: i64, + relay_public_key: &nostream_core::Pubkey, +) -> Option { + if event.pubkey == *relay_public_key { + return None; + } + + let settings: &Settings = context.settings.as_ref(); + let limits = settings + .limits + .as_ref() + .and_then(|limits| limits.event.as_ref()); + + if let Some(limits) = limits { + for content in content_limits(limits.content.as_ref()) { + if let Some(reason) = check_content_limit(event, content) { + return Some(reason); + } + } + + if let Some(created_at) = &limits.created_at + && let Err(error) = time::check_created_at( + event.created_at, + now, + created_at.max_positive_delta, + created_at.max_negative_delta, + ) + { + // The error's text already carries the capture's `rejected: ` prefix. + return Some(error.to_string()); + } + + if let Some(pow_settings) = limits.pow.as_ref().filter(|pow| pow.enabled) { + let required = required_difficulty(pow_settings, context, event); + let observed = pow::leading_zero_bits(event.id.as_bytes()); + + if observed < required { + return Some(format!("pow: difficulty {observed}<{required}")); + } + } else if let Some(minimum) = limits + .event_id + .as_ref() + .and_then(|event_id| event_id.min_leading_zero_bits) + .filter(|minimum| *minimum > 0) + { + let observed = pow::leading_zero_bits(event.id.as_bytes()); + + if observed < minimum { + return Some(format!("pow: difficulty {observed}<{minimum}")); + } + } + + if let Some(minimum) = limits + .pubkey + .as_ref() + .map(|pubkey| pubkey.min_leading_zero_bits) + .filter(|minimum| *minimum > 0) + { + let observed = pow::leading_zero_bits(event.pubkey.as_bytes()); + + if observed < minimum { + return Some(format!("pow: pubkey difficulty {observed}<{minimum}")); + } + } + + if let Some(pubkey_limits) = &limits.pubkey { + let pubkey = event.pubkey.to_string(); + + if pubkey_limits.whitelist.as_ref().is_some_and(|whitelist| { + !whitelist.is_empty() && !whitelist.iter().any(|entry| entry.0 == pubkey) + }) { + return Some("blocked: pubkey not allowed".to_owned()); + } + + if pubkey_limits + .blacklist + .as_ref() + .is_some_and(|blacklist| blacklist.iter().any(|entry| entry.0 == pubkey)) + { + return Some("blocked: pubkey not allowed".to_owned()); + } + } + + if let Some(kind_limits) = &limits.kind { + if kind_limits.whitelist.as_ref().is_some_and(|whitelist| { + !whitelist.is_empty() + && !whitelist + .iter() + .any(|selector| kind_selector_matches(*selector, event.kind)) + }) { + return Some(format!("blocked: event kind {} not allowed", event.kind)); + } + + if kind_limits.blacklist.as_ref().is_some_and(|blacklist| { + blacklist + .iter() + .any(|selector| kind_selector_matches(*selector, event.kind)) + }) { + return Some(format!("blocked: event kind {} not allowed", event.kind)); + } + } + } + + None +} + +/// `ContentLimits | ContentLimits[]`: one rule or a list of them. +fn content_limits(limits: Option<&ContentLimitsOrVec>) -> Vec<&ContentLimits> { + match limits { + Some(ContentLimitsOrVec::One(limit)) => vec![limit], + Some(ContentLimitsOrVec::Many(limits)) => limits.iter().collect(), + None => Vec::new(), + } +} + +fn check_content_limit(event: &Event, limit: &ContentLimits) -> Option { + let max_length = limit.max_length.filter(|max_length| *max_length > 0)?; + + // `String.prototype.length` counts UTF-16 code units. + let length = event.content.encode_utf16().count() as u64; + + if length <= max_length { + return None; + } + + let applies = limit.kinds.as_ref().is_none_or(|kinds| { + kinds + .iter() + .any(|selector| kind_selector_matches(*selector, event.kind)) + }); + + applies.then(|| format!("rejected: content is longer than {max_length} bytes")) +} + +/// The adaptive difficulty after the WoT reduction (`getCurrentDifficulty` + +/// `applyWotPowPolicy`). +/// +/// The WoT distance is `nostream-services`' graph, which no crate implements yet, so the +/// lookup yields `None` — the capture's own treatment of a pubkey outside the trust graph. +fn required_difficulty( + settings: &nostream_config::settings::AdaptivePowSettings, + context: &HandlerContext, + _event: &Event, +) -> u32 { + let config = AdaptivePowConfig { + enabled: settings.enabled, + floor_bits: settings.floor_bits, + ceiling_bits: settings.ceiling_bits, + target_events_per_second: settings.target_events_per_second, + period_ms: settings.period_ms, + }; -/// Phase 1 stub — job 005; will handle an EVENT message; see `src/handlers/event-message-handler.ts`. -pub async fn handle() -> Result<(), RelayError> { - Err(RelayError::NotImplemented { - module: "relay::handlers::event", - }) + let observed = context + .pow + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .current_events_per_second(settings.period_ms); + + let computed = + adaptive_pow::compute_difficulty(&config, observed).unwrap_or(settings.floor_bits); + + let thresholds: Option> = + settings.wot_thresholds.as_ref().map(|thresholds| { + thresholds + .iter() + .map(|threshold| WotPowThreshold { + max_distance: threshold.max_distance, + difficulty_factor: threshold.difficulty_factor, + }) + .collect() + }); + + adaptive_pow::apply_wot_pow_policy(computed, None, thresholds.as_deref()) +} + +/// `isAuthenticationRequired` (`:285-297`). +fn is_authentication_required( + event: &Event, + context: &HandlerContext, + relay_public_key: &nostream_core::Pubkey, +) -> Option { + let required = context + .settings + .nip42 + .as_ref() + .and_then(|nipp42| nipp42.auth_required) + .unwrap_or(false); + + if !required { + return None; + } + + if event.pubkey == *relay_public_key { + return None; + } + + if !context.authenticated_pubkeys().contains(&event.pubkey) { + return Some("auth-required: authentication is required to publish events".to_owned()); + } + + None +} + +/// `isProtectedEventBlocked` (`:299-331`). +async fn is_protected_event_blocked(event: &Event, context: &HandlerContext) -> Option { + if event.is_protected() && !context.authenticated_pubkeys().contains(&event.pubkey) { + return Some("auth-required: this event may only be published by its author".to_owned()); + } + + if embeds_protected_event(event, 0) == Some(true) { + return Some("blocked: reposts must not embed protected events".to_owned()); + } + + None +} + +/// The capture's `checkEmbedded`: a kind-6/16 repost whose content parses as an event that +/// verifies and carries the NIP-70 tag, at any depth up to ten. +fn embeds_protected_event(event: &Event, depth: u32) -> Option { + if depth > 10 { + return Some(false); + } + + if !REPOST_KINDS.contains(&event.kind) || event.content.is_empty() { + return Some(false); + } + + let value: serde_json::Value = match serde_json::from_str(&event.content) { + Ok(value) => value, + Err(_) => return Some(false), + }; + + let embedded = match nostream_core::messages::validate_event_value(&value) { + Ok(embedded) => embedded, + Err(_) => return Some(false), + }; + + if crypto::compute_event_id(&embedded).ok() != Some(embedded.id) + || crypto::verify_event_signature(&embedded).is_err() + { + return Some(false); + } + + if embedded.is_protected() { + return Some(true); + } + + embeds_protected_event(&embedded, depth + 1) +} + +/// `isBlockedByRequestToVanish` (`:360-374`). +async fn is_blocked_by_request_to_vanish( + event: &Event, + context: &HandlerContext, + relay_public_key: &nostream_core::Pubkey, +) -> Option { + if event.kind == Kind::REQUEST_TO_VANISH { + return None; + } + + if event.pubkey == *relay_public_key { + return None; + } + + match context.stores.users.is_vanished(&event.pubkey).await { + Ok(true) => Some("blocked: request to vanish active for pubkey".to_owned()), + Ok(false) => None, + Err(error) => { + tracing::error!(error = %error, "admission read failed; refusing the event"); + Some("blocked: pubkey not admitted".to_owned()) + } + } +} + +/// `isUserAdmitted` (`:445-522`). +async fn is_user_admitted( + event: &Event, + context: &HandlerContext, + relay_public_key: &nostream_core::Pubkey, +) -> Option { + let settings: &Settings = context.settings.as_ref(); + let payments_enabled = settings + .payments + .as_ref() + .is_some_and(|payments| payments.enabled); + let nip43_enabled = settings.nip43.as_ref().is_some_and(|nip43| nip43.enabled); + + if !payments_enabled && !nip43_enabled { + return None; + } + + if event.pubkey == *relay_public_key { + return None; + } + + // Join, leave and invite requests are the admission flow itself, and the invite request + // must reach its strategy to answer "use a REQ instead". + if NIP43_REQUEST_KINDS.contains(&event.kind) { + return None; + } + + let fee_schedules = payments_enabled + .then(|| { + settings + .payments + .as_ref() + .map(|payments| &payments.fee_schedules) + }) + .flatten() + .map(|schedules| { + schedules + .admission + .iter() + .filter(|schedule| { + schedule.enabled + && !schedule + .whitelists + .as_ref() + .and_then(|whitelists| whitelists.pubkeys.as_ref()) + .is_some_and(|pubkeys| { + pubkeys + .iter() + .any(|pubkey| pubkey.0 == event.pubkey.to_string()) + }) + && !schedule + .whitelists + .as_ref() + .and_then(|whitelists| whitelists.event_kinds.as_ref()) + .is_some_and(|kinds| { + kinds + .iter() + .any(|selector| kind_selector_matches(*selector, event.kind)) + }) + }) + .count() + }); + + let admission_fee_required = fee_schedules.is_some_and(|count| count > 0); + + if !admission_fee_required && !nip43_enabled { + return None; + } + + let not_admitted_reason = if nip43_enabled { + "restricted: pubkey not admitted; send a join request (kind 28934) with a valid invite code" + } else { + "blocked: pubkey not admitted" + }; + + let user = match context.stores.users.find_by_pubkey(&event.pubkey).await { + Ok(user) => user, + Err(error) => { + tracing::error!(error = %error, "user read failed; refusing the event"); + return Some(not_admitted_reason.to_owned()); + } + }; + + let Some(user) = user.filter(|user| user.is_admitted) else { + return Some(not_admitted_reason.to_owned()); + }; + + let min_balance = if payments_enabled { + settings + .limits + .as_ref() + .and_then(|limits| limits.event.as_ref()) + .and_then(|event| event.pubkey.as_ref()) + .map_or(0, |pubkey| pubkey.min_balance) + } else { + 0 + }; + + if min_balance > 0 && user.balance < min_balance { + return Some("blocked: insufficient balance".to_owned()); + } + + None } + +/// `checkNip05Verification` (`:542-590`). +/// +/// The background refresh `processNip05Metadata` fires for a kind-0 event is the NIP-05 +/// verifier's (`nostream-services`); it has no wire effect, so it is not run here. +async fn check_nip05_verification( + event: &Event, + context: &HandlerContext, + relay_public_key: &nostream_core::Pubkey, +) -> Option { + let settings = context.settings.nip05.as_ref()?; + + if settings.mode != nostream_config::settings::Nip05Mode::Enabled { + return None; + } + + if event.pubkey == *relay_public_key { + return None; + } + + if matches!(event.kind, Kind(28_934) | Kind(28_936) | SET_METADATA_KIND) { + return None; + } + + let verification = match context.stores.nip05.find_by_pubkey(&event.pubkey).await { + Ok(verification) => verification, + Err(error) => { + tracing::error!(error = %error, "nip05 read failed; refusing the event"); + return Some("blocked: NIP-05 verification required".to_owned()); + } + }; + + let Some(verification) = verification else { + return Some("blocked: NIP-05 verification required".to_owned()); + }; + + if !nip05::is_domain_allowed( + &verification.domain, + settings.domain_whitelist.as_deref(), + settings.domain_blacklist.as_deref(), + ) { + return Some("blocked: NIP-05 domain not allowed".to_owned()); + } + + let Some(last_verified_at) = verification.last_verified_at else { + return Some("blocked: NIP-05 verification required".to_owned()); + }; + + let expiration = settings + .verify_expiration + .map_or(nip05::DEFAULT_NIP05_VERIFY_EXPIRATION_MS, |value| { + i64::try_from(value).unwrap_or(i64::MAX) + }); + + let elapsed = context + .now_ms() + .saturating_sub(u64::try_from(last_verified_at.timestamp_millis()).unwrap_or_default()); + + if elapsed > u64::try_from(expiration).unwrap_or(u64::MAX) { + return Some("blocked: NIP-05 verification expired".to_owned()); + } + + None +} + +/// `ALL_RELAYS`, re-exported for the tests that build kind-62 events. +pub const ALL_RELAYS_TAG: &str = ALL_RELAYS; + +/// `EventKinds.REACTION`, re-exported for the tests. +pub const REACTION: Kind = REACTION_KIND; diff --git a/crates/nostream-relay/src/handlers/mod.rs b/crates/nostream-relay/src/handlers/mod.rs index 285fbe599..d2fe1c0fc 100644 --- a/crates/nostream-relay/src/handlers/mod.rs +++ b/crates/nostream-relay/src/handlers/mod.rs @@ -1,5 +1,9 @@ -//! The five wire message handlers. Mirrors `src/handlers/*-message-handler.ts` at the capture -//! commit (the NIP-77 `neg-*` handlers are at the oracle, not here). +//! The five wire message handlers. Mirrors `src/handlers/*-message-handler.ts` at the +//! capture commit (the NIP-77 `neg-*` handlers are at the oracle, not here). +//! +//! Handlers never touch the transport: they receive a [`HandlerContext`], which carries the +//! write-point sink, the read-through settings, the clock, the connection state and the +//! repository seams. pub mod auth; pub mod count; @@ -7,11 +11,185 @@ pub mod event; pub mod subscribe; pub mod unsubscribe; -use nostream_core::Pubkey; +use std::collections::HashSet; +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; + +use tokio::sync::broadcast; + +use nostream_config::settings::{Nip42Settings, Settings}; +use nostream_core::adaptive_pow::AdaptivePowState; +use nostream_core::clock::Clock; +use nostream_core::messages::{ClosedMessage, CommandResult, NoticeMessage, RelayMessage}; +use nostream_core::nip42; +use nostream_core::{Event, KindSelector, Pubkey}; +use nostream_storage::repositories::{ + DvmJobRepository, EventRepository, InviteCodeRepository, Nip05VerificationRepository, + ReportRepository, UserRepository, +}; + +use crate::connection::Connection; +use crate::hidden_content::HiddenContentCache; +use crate::limits::RateLimits; +use crate::relay_key::RelayKeySource; +use crate::sink::FrameSink; + +/// The repository seams a handler may call. Every one is a trait object, so a test supplies +/// in-memory doubles and no handler knows which backend it has. +pub struct Stores { + pub events: Arc, + pub users: Arc, + pub nip05: Arc, + pub invites: Arc, + pub dvm_jobs: Arc, + pub reports: Arc, +} /// The context one socket shares across handlers (`src/@types/message-handlers.ts`). -#[derive(Debug, Clone, Default)] +/// +/// `settings` is the loaded document, read through on every check (R27); `pow` is this +/// process's adaptive-difficulty sample, which the capture keeps as module state. +#[derive(Clone)] pub struct HandlerContext { - pub authenticated_pubkey: Option, - pub subscription_id: Option, + pub settings: Arc, + pub clock: Arc, + pub sink: FrameSink, + pub connection: Arc, + pub stores: Arc, + pub limits: Arc, + pub hidden: Arc, + pub keys: Arc, + pub broadcast: broadcast::Sender, + pub pow: Arc>, +} + +impl HandlerContext { + /// `Math.floor(Date.now() / 1000)`. + pub fn now_secs(&self) -> i64 { + self.clock.now_secs() + } + + /// `Date.now()`. + pub fn now_ms(&self) -> u64 { + self.clock.now_ms() + } + + /// `settings.info.relay_url`. + pub fn relay_url(&self) -> &str { + &self.settings.info.relay_url + } + + /// The relay's own pubkey, when it has a signing key (`getRelayPublicKey`, which throws + /// in the capture when there is none; every caller here treats `None` as "no key"). + pub fn relay_public_key(&self) -> Option { + self.keys.try_public_key(self.relay_url()) + } + + /// The live authenticated pubkeys for this socket. + pub fn authenticated_pubkeys(&self) -> HashSet { + self.connection.authenticated_pubkeys(self.now_secs()) + } + + /// The restricted-read kinds this relay honours (`getRestrictedReadKinds`). + pub fn restricted_read_kinds(&self) -> Vec { + restricted_read_kinds(self.settings.nip42.as_ref()) + } + + /// `createReadAuthorizationGuard` bound to this socket's authenticated set: the caller + /// takes one snapshot, and every event in the read is judged against it. + pub fn read_authorization_guard(&self) -> ReadGuard { + ReadGuard { + restricted: self.restricted_read_kinds(), + authenticated: self.authenticated_pubkeys(), + } + } + + /// `["OK", , , ]` with the optional backoff slot. + pub fn send_ok( + &self, + event_id: nostream_core::EventId, + accepted: bool, + message: &str, + params: Option, + ) { + self.sink.send(&RelayMessage::Ok(CommandResult { + event_id, + accepted, + message: message.to_owned(), + params, + })); + } + + /// `["CLOSED", , ]` with the optional backoff slot. + pub fn send_closed(&self, subscription_id: &str, message: &str, params: Option) { + self.sink.send(&RelayMessage::Closed(ClosedMessage { + subscription_id: subscription_id.to_owned(), + message: message.to_owned(), + params, + })); + } + + /// `["NOTICE", ]`. + pub fn send_notice(&self, message: &str) { + self.sink.send(&RelayMessage::Notice(NoticeMessage { + message: message.to_owned(), + })); + } +} + +/// One socket's read-authorisation decision (`createReadAuthorizationGuard`). +#[derive(Debug, Clone)] +pub struct ReadGuard { + restricted: Vec, + authenticated: HashSet, +} + +impl ReadGuard { + /// Whether this socket may read `event`. + pub fn allows(&self, event: &Event) -> bool { + nip42::is_read_authorized(event, &self.restricted, &self.authenticated) + } +} + +/// `getRestrictedReadKinds` (`src/utils/nip42.ts:15-23`): the configured kinds when +/// restricted reads are enabled, the NIP-42 defaults when no list is given, none when off. +pub fn restricted_read_kinds(settings: Option<&Nip42Settings>) -> Vec { + let Some(settings) = settings else { + return Vec::new(); + }; + + let enabled = settings + .restricted_reads + .as_ref() + .is_some_and(|reads| reads.enabled); + + let configured: Vec = settings + .restricted_reads + .as_ref() + .and_then(|reads| reads.kinds.clone()) + .unwrap_or_default(); + + nip42::restricted_read_kinds(enabled, &configured) +} + +/// One handler's future, boxed so a connection task can spawn it without naming its type. +pub type HandlerFuture = Pin + Send>>; + +/// Runs one decoded client message as the capture's `messageHandlerFactory` switch does. +pub fn dispatch( + message: nostream_core::messages::ClientMessage, + context: Arc, +) -> HandlerFuture { + use nostream_core::messages::ClientMessage; + + Box::pin(async move { + match message { + ClientMessage::Event(event) => event::handle(event, &context).await, + ClientMessage::Subscribe(message) => subscribe::handle(message, &context).await, + ClientMessage::Count(message) => count::handle(message, &context).await, + ClientMessage::Unsubscribe(message) => unsubscribe::handle(message, &context), + ClientMessage::Auth(message) => auth::handle(message, &context).await, + } + }) } diff --git a/crates/nostream-relay/src/handlers/subscribe.rs b/crates/nostream-relay/src/handlers/subscribe.rs index 9cb31c8bb..71331856c 100644 --- a/crates/nostream-relay/src/handlers/subscribe.rs +++ b/crates/nostream-relay/src/handlers/subscribe.rs @@ -1,10 +1,366 @@ //! The `subscribe-message-handler.ts` handler. +//! +//! Normalises the filters (dedupe, drop `search` when NIP-50 is off), applies the six +//! `canSubscribe` limits with the capture's strings and its two typos, closes an +//! all-restricted REQ from an unauthenticated client, registers the subscription, mints a +//! NIP-43 invite code when the filters ask for one, then streams the stored events and ends +//! with `EOSE`. A `CLOSE` or a tripped message limiter aborts the read with no further frame. + +use futures_util::StreamExt; +use nostream_core::expiration; +use nostream_core::filter::{count_filter_values, normalise_subscription}; +use nostream_core::messages::{EoseMessage, OutgoingEventMessage, RelayMessage, SubscribeMessage}; +use nostream_core::nip42; +use nostream_core::{Filter, Kind}; use crate::error::RelayError; +use crate::handlers::HandlerContext; + +/// `EventKinds.NIP43_INVITE_REQUEST`. +const INVITE_REQUEST_KIND: Kind = Kind(28_935); + +/// The handler's entry point (`handleMessage`, `:51-82`). +pub async fn handle(message: SubscribeMessage, context: &HandlerContext) { + let subscription_id = message.subscription_id; + let search_enabled = context + .settings + .nip50 + .as_ref() + .and_then(|nip50| nip50.enabled) + .unwrap_or(false); + let filters = normalise_subscription(message.filters, search_enabled); + + if let Some(reason) = can_subscribe(&subscription_id, &filters, context) { + context.send_notice(&format!("Subscription rejected: {reason}")); + return; + } + + let restricted = context.restricted_read_kinds(); + + if nip42::is_subscription_auth_required(&restricted, &filters, &context.authenticated_pubkeys()) + { + context.send_closed( + &subscription_id, + "auth-required: authentication is required to request these event kinds", + None, + ); + return; + } + + context + .connection + .subscribe(crate::connection::Subscription { + id: subscription_id.clone(), + filters: filters.clone(), + }); + + maybe_issue_invite_code(&subscription_id, &filters, context).await; + + fetch_and_send(&subscription_id, &filters, context).await; +} + +/// NIP-43: kind 28935 is never published by a client and never stored. A REQ for it asks the +/// relay to mint an invite code, answered with a relay-signed event on this socket only +/// (`maybeIssueInviteCode`, `:89-112`). `fetchAndSend` still runs: its EOSE ends the stored +/// read. +async fn maybe_issue_invite_code( + subscription_id: &str, + filters: &[Filter], + context: &HandlerContext, +) { + let asked = filters.iter().any(|filter| { + filter + .kinds + .as_ref() + .is_some_and(|kinds| kinds.contains(&INVITE_REQUEST_KIND)) + }); + + if !asked { + return; + } + + match mint_invite_code(context).await { + Ok(Some(event)) => { + context + .sink + .send(&RelayMessage::Event(OutgoingEventMessage { + subscription_id: subscription_id.to_owned(), + event, + })); + } + Ok(None) => {} + Err(error) => { + // A failed mint must not abort the subscription: fall through to EOSE. + tracing::warn!(error = %error, "unable to mint an invite code"); + } + } +} + +/// `mintInviteCode` (`:114-174`): every guard must pass. +async fn mint_invite_code( + context: &HandlerContext, +) -> Result, RelayError> { + let Some(nip43) = context.settings.nip43.as_ref() else { + return Ok(None); + }; + + if !nip43.enabled || nip43.allow_invite_requests != Some(true) { + return Ok(None); + } + + // `isRelaySelfConsistent`: an `info.self` that is configured must be the signing pubkey. + // The capture also accepts an `npub`; this decodes only the hex form and refuses to mint + // for an `npub` it cannot compare (ADR 0004). + let Some(keys) = context.keys.resolve(context.relay_url()).ok() else { + return Ok(None); + }; + + if let Some(configured) = context + .settings + .info + .self_ + .as_deref() + .filter(|value| !value.is_empty()) + && !configured.eq_ignore_ascii_case(&keys.public_key.to_string()) + { + tracing::debug!( + "info.self does not match the relay signing pubkey; refusing to issue invite codes" + ); + return Ok(None); + } + + let authenticated = context + .connection + .authenticated_pubkeys_in_order(context.now_secs()); + + if authenticated.is_empty() { + tracing::debug!("ignoring invite request from an unauthenticated client"); + return Ok(None); + } + + let requester = match nip43 + .invite_request_whitelist + .as_ref() + .filter(|list| !list.is_empty()) + { + Some(whitelist) => authenticated + .iter() + .find(|pubkey| whitelist.iter().any(|entry| entry.0 == pubkey.to_string())) + .copied(), + None => authenticated.first().copied(), + }; + + let Some(requester) = requester else { + tracing::debug!( + "ignoring invite request: no authenticated pubkey is on inviteRequestWhitelist" + ); + return Ok(None); + }; + + match context.limits.invite_limited(&requester) { + Ok(true) => return Ok(None), + Ok(false) => {} + Err(error) => { + // Fail closed: minting writes a row, so an unavailable limiter must not open the + // gate. + tracing::warn!(error = %error, "invite rate limiter unavailable; skipping the mint"); + return Ok(None); + } + } + + let now = context.now_secs(); + let limits = nostream_core::nip43::resolve_invite_limits( + nip43.default_max_uses, + nip43.invite_code_expiry_seconds, + now, + ) + .map_err(|error| RelayError::Configuration(error.to_string()))?; + + let code = nostream_core::nip43::generate_invite_code() + .map_err(|error| RelayError::Configuration(error.to_string()))?; + + let options = nostream_storage::repositories::CreateInviteCodeOptions { + expires_at: limits + .expires_at + .and_then(|seconds| chrono::DateTime::from_timestamp(seconds, 0)), + remaining_uses: nostream_storage::repositories::FieldUpdate::Set( + i32::try_from(limits.remaining_uses).unwrap_or(i32::MAX), + ), + created_by: Some(requester), + }; + + context.stores.invites.create(&code, &options).await?; + + let event = + nostream_core::nip43::build_invite_code_event(&keys.secret_key, &code, &limits, now) + .map_err(|error| RelayError::Configuration(error.to_string()))?; + + Ok(Some(event)) +} + +/// `fetchAndSend` (`:208-252`): the stored read, abortable, ending in `EOSE` unless aborted. +async fn fetch_and_send(subscription_id: &str, filters: &[Filter], context: &HandlerContext) { + let mut cancellation = context.connection.begin_read(subscription_id); + + let stream = match context.stores.events.find_by_filters(filters).await { + Ok(stream) => stream, + Err(error) => { + tracing::error!(error = %error, "unable to stream events"); + context.connection.end_read(subscription_id); + return; + } + }; + + futures_util::pin_mut!(stream); + + let now = context.now_secs(); + let authenticated = context.authenticated_pubkeys(); + let restricted = context.restricted_read_kinds(); + let read_authorized = |event: &nostream_core::Event| { + nip42::is_read_authorized(event, &restricted, &authenticated) + }; + let matches_any = + |event: &nostream_core::Event| filters.iter().any(|filter| filter.matches(event)); + + loop { + if *cancellation.borrow() { + context.connection.end_read(subscription_id); + return; + } + + let record = tokio::select! { + changed = cancellation.changed() => { + // An aborted read sends no frame at all, not even EOSE. + if changed.is_err() || *cancellation.borrow() { + context.connection.end_read(subscription_id); + return; + } + + continue; + } + record = stream.next() => match record { + Some(record) => record, + None => break, + }, + }; + + match record { + Ok(record) => { + if record.deleted_at.is_some() { + continue; + } + + let event = record.event; + + if expiration::is_expired_event(&event, now) + || !read_authorized(&event) + || !matches_any(&event) + { + continue; + } + + context + .sink + .send(&RelayMessage::Event(OutgoingEventMessage { + subscription_id: subscription_id.to_owned(), + event, + })); + } + Err(error) => { + tracing::error!(error = %error, "error streaming events"); + break; + } + } + } + + context.connection.end_read(subscription_id); + + context.sink.send(&RelayMessage::Eose(EoseMessage { + subscription_id: subscription_id.to_owned(), + })); +} + +/// `canSubscribe` (`:258-301`): six reasons, the capture's strings — including +/// `susbscription` and `less then` — and limits at `0` or absent disabled. +fn can_subscribe( + subscription_id: &str, + filters: &[Filter], + context: &HandlerContext, +) -> Option { + let limits = context + .settings + .limits + .as_ref() + .and_then(|limits| limits.client.as_ref()) + .and_then(|client| client.subscription.as_ref()); + + let existing = context.connection.subscription(subscription_id); + + if context + .connection + .has_identical_subscription(subscription_id, filters) + { + return Some(format!( + "Duplicate subscription {subscription_id}: Ignoring" + )); + } + + let max_subscriptions = limits + .and_then(|limits| limits.max_subscriptions) + .unwrap_or(0); + + if max_subscriptions > 0 + && existing + .as_ref() + .is_none_or(|subscription| subscription.filters.is_empty()) + && context.connection.subscription_count() as u64 + 1 > max_subscriptions + { + return Some(format!( + "Too many subscriptions: Number of subscriptions must be less than or equal to {max_subscriptions}" + )); + } + + let max_filters = limits.and_then(|limits| limits.max_filters).unwrap_or(0); + + if max_filters > 0 && filters.len() as u64 > max_filters { + return Some(format!( + "Too many filters: Number of filters per susbscription must be less then or equal to {max_filters}" + )); + } + + let max_filter_values = limits + .and_then(|limits| limits.max_filter_values) + .unwrap_or(0); + + if max_filter_values > 0 + && filters + .iter() + .any(|filter| count_filter_values(filter) as u64 > max_filter_values) + { + return Some(format!( + "Too many filter values: Number of values per filter must be less than or equal to {max_filter_values}" + )); + } + + let max_limit = limits.and_then(|limits| limits.max_limit).unwrap_or(0); + + if max_limit > 0 + && filters + .iter() + .any(|filter| filter.limit.is_some_and(|limit| limit > max_limit)) + { + return Some(format!( + "Limit too high: Filter limit must be less than or equal to {max_limit}" + )); + } + + if let Some(max_id_length) = limits.and_then(|limits| limits.max_subscription_id_length) + && subscription_id.len() as u64 > max_id_length + { + return Some(format!( + "Subscription ID too long: Subscription ID must be less or equal to {max_id_length}" + )); + } -/// Phase 1 stub — job 005; will handle a REQ message; see `src/handlers/subscribe-message-handler.ts`. -pub async fn handle() -> Result<(), RelayError> { - Err(RelayError::NotImplemented { - module: "relay::handlers::subscribe", - }) + None } diff --git a/crates/nostream-relay/src/handlers/unsubscribe.rs b/crates/nostream-relay/src/handlers/unsubscribe.rs index e153f0455..51b422a89 100644 --- a/crates/nostream-relay/src/handlers/unsubscribe.rs +++ b/crates/nostream-relay/src/handlers/unsubscribe.rs @@ -1,10 +1,14 @@ //! The `unsubscribe-message-handler.ts` handler. +//! +//! A `CLOSE` removes the subscription, aborts its in-flight read, and emits **no frame** +//! (`:9-11`; measured: `CLOSE s1` → no frame). -use crate::error::RelayError; +use nostream_core::messages::UnsubscribeMessage; -/// Phase 1 stub — job 005; will handle a CLOSE message; see `src/handlers/unsubscribe-message-handler.ts`. -pub async fn handle() -> Result<(), RelayError> { - Err(RelayError::NotImplemented { - module: "relay::handlers::unsubscribe", - }) +use crate::handlers::HandlerContext; + +/// The handler's entry point (`handleMessage`). +pub fn handle(message: UnsubscribeMessage, context: &HandlerContext) { + context.connection.abort_read(&message.subscription_id); + context.connection.unsubscribe(&message.subscription_id); } diff --git a/crates/nostream-relay/src/hidden_content.rs b/crates/nostream-relay/src/hidden_content.rs new file mode 100644 index 000000000..14fbe49fd --- /dev/null +++ b/crates/nostream-relay/src/hidden_content.rs @@ -0,0 +1,166 @@ +//! The NIP-56 live-hiding mirror. Mirrors `src/utils/hidden-content-cache.ts`. +//! +//! An in-process set of actionable-report targets, consulted on the live-dispatch path only: +//! a snapshot replaces it wholesale, a local mark adds to it immediately, and a periodic +//! tick re-reads it from `ReportRepository::find_actionable_targets` — but only while +//! `nip56.hideActionableReports` is true. +//! +//! The capture keeps module-level sets; here they are [`HiddenContentCache`], owned by the +//! relay, so a test can seed and inspect them. + +use std::collections::HashSet; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::Duration; + +use nostream_config::settings::Settings; +use nostream_core::clock::Clock; +use nostream_core::{Event, EventId, Pubkey}; +use nostream_storage::repositories::ActionableTarget; +use nostream_storage::repositories::ReportRepository; + +/// The mirror's default refresh interval (`DEFAULT_REFRESH_INTERVAL_MS`, +/// `src/utils/hidden-content-cache.ts:7`). +pub const DEFAULT_REFRESH_INTERVAL: Duration = Duration::from_secs(30); + +/// The actionable-report targets this worker believes are hidden. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct HiddenTargets { + pub pubkeys: HashSet, + pub event_ids: HashSet, +} + +/// The in-process mirror of actionable-report targets. +#[derive(Debug, Default)] +pub struct HiddenContentCache { + targets: RwLock, + /// Refreshes performed, i.e. ticks that actually queried the repository. A tick with + /// `hideActionableReports` false queries nothing and does not count. + refreshes: Mutex, +} + +impl HiddenContentCache { + /// An empty mirror. + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + /// Whether an event's author or id is a hidden target (`isHidden`). + pub fn is_hidden(&self, event: &Event) -> bool { + let targets = self + .targets + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + targets.pubkeys.contains(&event.pubkey) || targets.event_ids.contains(&event.id) + } + + /// Adds one target ahead of the next refresh (`markActionableTarget`). + pub fn mark_actionable_target(&self, target: &ActionableTarget) { + let mut targets = self + .targets + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + if let Some(pubkey) = target.reported_pubkey { + targets.pubkeys.insert(pubkey); + } + + if let Some(event_id) = target.reported_event_id { + targets.event_ids.insert(event_id); + } + } + + /// Replaces the whole mirror with one snapshot (`refreshFromDatabase`). + pub fn replace(&self, snapshot: &[ActionableTarget]) { + let next = HiddenTargets { + pubkeys: snapshot + .iter() + .filter_map(|target| target.reported_pubkey) + .collect(), + event_ids: snapshot + .iter() + .filter_map(|target| target.reported_event_id) + .collect(), + }; + + *self + .targets + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = next; + } + + /// The current snapshot, for tests. + pub fn snapshot(&self) -> HiddenTargets { + self.targets + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + + /// How many refreshes have queried the repository. + pub fn refreshes(&self) -> u64 { + *self + .refreshes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + /// One refresh tick: queries the repository only while `hide_actionable_reports` holds, + /// then replaces the mirror with the snapshot (`startHiddenContentCache`'s + /// `pollIfEnabled`). + pub async fn refresh( + &self, + repository: &dyn ReportRepository, + settings: &Settings, + ) -> Result<(), nostream_storage::StorageError> { + let hiding = settings + .nip56 + .as_ref() + .is_some_and(|nipp56| nipp56.enabled && nipp56.hide_actionable_reports); + + if !hiding { + return Ok(()); + } + + let snapshot = repository.find_actionable_targets().await?; + + self.replace(&snapshot); + + *self + .refreshes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) += 1; + + Ok(()) + } + + /// Seeds the mirror, then re-reads it on `interval` until the returned task is dropped. + /// + /// `interval` is injectable (`startHiddenContentCache`'s third argument); the daemon + /// passes [`DEFAULT_REFRESH_INTERVAL`]. + pub fn start( + self: &Arc, + repository: Arc, + settings: Arc, + interval: Duration, + clock: Arc, + ) -> tokio::task::JoinHandle<()> { + let cache = Arc::clone(self); + + tokio::spawn(async move { + let mut ticker = tokio::time::interval(interval); + + loop { + ticker.tick().await; + + if let Err(error) = cache.refresh(repository.as_ref(), settings.as_ref()).await { + tracing::warn!( + error = %error, + now = clock.now_secs(), + "failed to refresh the hidden-content cache" + ); + } + } + }) + } +} diff --git a/crates/nostream-relay/src/lib.rs b/crates/nostream-relay/src/lib.rs index 2c3b0f91e..cb52e20a6 100644 --- a/crates/nostream-relay/src/lib.rs +++ b/crates/nostream-relay/src/lib.rs @@ -1,5 +1,6 @@ -//! `nostream-relay` owns the Nostr wire: the five message handlers, the event strategies, the -//! per-connection state, the rate limiters and the cross-worker broadcast fan-out. +//! `nostream-relay` owns the Nostr wire: the transport and per-connection state machine, the +//! positional frame codec's use, the five message handlers, the event strategies and their +//! ordered registry, the NIP-42 session and read guards, and the rate limiters. //! //! It owns these TypeScript paths from the capture commit //! (`242a3001d77149e4d70151ec03060c4ef44a1547`): @@ -16,13 +17,26 @@ //! - `src/factories/{event-strategy-factory,message-handler-factory, //! websocket-adapter-factory,rate-limiter-factory}.ts` //! -//! Phase 1 carries the module map and labelled stubs only: no protocol behaviour, no WebSocket -//! dependency, no transport. The NIP-77 `neg-*` handlers exist at the porting oracle -//! (`041fe34e`) and are deliberately absent here. Phase 2+ areas this crate does not own: the -//! HTTP surface (nostream-admin) and the probe family (nostream-services). +//! **Implemented here:** the transport (`transport.rs`: `tokio-tungstenite` 0.30, the +//! settings-derived message limit, the close codes, the drain timeout), the per-socket state +//! machine (`connection.rs`, `relay.rs`: subscriptions, liveness, drain, live dispatch), the +//! five handlers, the strategy trait with its ordered registry and the `default` strategy, +//! the NIP-42 session and read guards' wiring, the two rate limiters over an in-process +//! store, and the NIP-56 hidden-content mirror. The positional codec, `validation::validate_event` +//! and `nip42::{authenticate, generate_challenge}` are `nostream-core`'s. //! -//! The event acceptance gate this crate will build owns the *policy*: the kind-specific tag -//! rules, which `created_at` deltas the settings supply, and where the check runs. The pure +//! **Still stubbed, each labelled with the job that fills it:** the fourteen non-default +//! event strategies (job 009), `NegentropySession`/`open_negentropy_session` (a NIP-77 job +//! cut against `041fe34e`, where the `neg-*` handlers first exist), and the cross-worker +//! broadcast `publish`/`deduplicate` (unassigned: `src/relay-broadcast/redis-relay-broadcast-fanout.ts` +//! is named by no bullet of issue #865). `broadcast.rs` therefore carries no behaviour; the +//! in-process fan-out this job needs is `Relay::hub`. +//! +//! Phase 2+ areas this crate does not own: the HTTP surface and NIP-11 document +//! (`nostream-admin`), and the probe family and NIP-66 monitor (`nostream-services`). +//! +//! The event acceptance gate this crate owns is the *policy*: the kind-specific tag rules, +//! which `created_at` deltas the settings supply, and where the check runs. The pure //! predicate it calls — `nostream_core::time::check_created_at` — is core's, along with the //! rest of the reference-time arithmetic; do not re-implement it here. @@ -30,7 +44,15 @@ pub mod broadcast; pub mod connection; pub mod error; pub mod handlers; +pub mod hidden_content; pub mod limits; +pub mod relay; +pub mod relay_key; +pub mod sink; pub mod strategies; +pub mod testing; +pub mod transport; +pub mod unavailable; pub use error::RelayError; +pub use relay::{Relay, RelayConfig}; diff --git a/crates/nostream-relay/src/limits.rs b/crates/nostream-relay/src/limits.rs index 408b12d95..47337f963 100644 --- a/crates/nostream-relay/src/limits.rs +++ b/crates/nostream-relay/src/limits.rs @@ -1,5 +1,29 @@ -//! Rate limiting. Mirrors `src/utils/sliding-window-rate-limiter.ts` and -//! `src/utils/ewma-rate-limiter.ts`. +//! Rate limiting. Mirrors `src/utils/sliding-window-rate-limiter.ts`, +//! `src/utils/ewma-rate-limiter.ts`, `src/factories/rate-limiter-factory.ts` and the three +//! wiring points (`rate-limiter-middleware.ts`, `web-socket-adapter.ts:264-310`, +//! `event-message-handler.ts:376-436`, `subscribe-message-handler.ts:179-206`). +//! +//! The capture's limiters are Redis singletons executing Lua; here they are in-process +//! stores whose time comes from [`nostream_core::clock::Clock`]. The window *semantics* — +//! what is counted, what is recorded and what is refused — are the scripts' (ADR 0004). +//! +//! | script | rule | +//! |---|---| +//! | sliding window | drop scores ≤ `now − period`; `sum + step > rate` refuses **without recording**; `sum + step == rate` is allowed | +//! | EWMA | `R' = R·e^(−ln2/period·Δt) + step`, stored **before** the comparison; `R' > rate` refuses | +//! +//! Both stores expire a key after `ceil(period / 1000)` seconds, which is the TTL the +//! scripts set (`ewma-rate-limiter.ts:22`, `sliding-window-rate-limiter.ts:43`). + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use nostream_config::settings::{ + ConnectionLimits, EventLimits, EventRateLimit, InviteLimits, MessageLimits, MessageRateLimit, + RateLimit, RateLimiterStrategy, Settings, +}; +use nostream_core::clock::Clock; +use nostream_core::{Event, KindSelector, Pubkey}; use crate::error::RelayError; @@ -10,32 +34,480 @@ pub struct RateLimitConfig { pub rate: u64, } -/// A fixed-window or sliding-window counter. -#[derive(Debug, Clone, Default)] -pub struct SlidingWindowRateLimiter; +impl From<&RateLimit> for RateLimitConfig { + fn from(limit: &RateLimit) -> Self { + Self { + period_ms: limit.period, + rate: limit.rate, + } + } +} + +impl From<&MessageRateLimit> for RateLimitConfig { + fn from(limit: &MessageRateLimit) -> Self { + Self { + period_ms: limit.period, + rate: limit.rate, + } + } +} + +impl From<&EventRateLimit> for RateLimitConfig { + fn from(limit: &EventRateLimit) -> Self { + Self { + period_ms: limit.period, + rate: limit.rate, + } + } +} + +/// One sliding-window hit: the instant it was recorded and the weight it carries. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct SlidingEntry { + at_ms: u64, + step: u64, +} + +/// The sliding-window store (`SLIDING_WINDOW_RATE_LIMITER_LUA_SCRIPT`). +#[derive(Debug)] +pub struct SlidingWindowRateLimiter { + clock: Arc, + entries: Mutex>>, +} impl SlidingWindowRateLimiter { - /// Counts one event and reports whether it is under the limit. + /// A limiter reading time from `clock`. + pub fn new(clock: Arc) -> Self { + Self { + clock, + entries: Mutex::new(HashMap::new()), + } + } + + /// Counts one hit and reports whether it is **limited**. /// - /// Phase 1 stub — job 005; will slide the window and count hits per key; see `src/utils/sliding-window-rate-limiter.ts`. - pub fn hit(&self, _key: &str, _config: &RateLimitConfig) -> Result { - Err(RelayError::NotImplemented { - module: "relay::limits::SlidingWindowRateLimiter::hit", - }) + /// The window start is inclusive on removal (`ZREMRANGEBYSCORE 0 windowStart`) and the + /// comparison is exclusive, so `sum + step == rate` is allowed and a refused hit is not + /// recorded. + pub fn hit(&self, key: &str, step: u64, config: &RateLimitConfig) -> Result { + let now = self.clock.now_ms(); + let window_start = now.saturating_sub(config.period_ms); + + let mut store = self.store()?; + let entries = store.entry(key.to_owned()).or_default(); + + entries.retain(|entry| entry.at_ms > window_start); + + let sum = entries + .iter() + .fold(0u64, |total, entry| total.saturating_add(entry.step)); + + if sum.saturating_add(step) > config.rate { + return Ok(true); + } + + entries.push(SlidingEntry { at_ms: now, step }); + + Ok(false) } + + /// How many hits the key currently carries inside its window. + pub fn len(&self, key: &str) -> usize { + self.entries + .lock() + .map(|store| store.get(key).map_or(0, Vec::len)) + .unwrap_or(0) + } + + /// Whether the key currently carries no hits. + pub fn is_empty(&self, key: &str) -> bool { + self.len(key) == 0 + } + + fn store( + &self, + ) -> Result>>, RelayError> { + self.entries + .lock() + .map_err(|error| RelayError::RateLimiter(format!("sliding window store: {error}"))) + } +} + +/// One EWMA key: the decayed rate and the instant it was written. +#[derive(Debug, Clone, Copy, PartialEq)] +struct EwmaEntry { + rate: f64, + at_ms: u64, } -/// An exponentially weighted moving average limiter (`strategy: ewma`). -#[derive(Debug, Clone, Default)] -pub struct EwmaRateLimiter; +/// The EWMA store (`rateLimitScript`). +#[derive(Debug)] +pub struct EwmaRateLimiter { + clock: Arc, + entries: Mutex>, +} impl EwmaRateLimiter { - /// Counts one event and reports whether it is under the limit. + /// A limiter reading time from `clock`. + pub fn new(clock: Arc) -> Self { + Self { + clock, + entries: Mutex::new(HashMap::new()), + } + } + + /// Counts one hit and reports whether it is **limited**. /// - /// Phase 1 stub — job 005; will update the EWMA and compare against the rate; see `src/utils/ewma-rate-limiter.ts`. - pub fn hit(&self, _key: &str, _config: &RateLimitConfig) -> Result { - Err(RelayError::NotImplemented { - module: "relay::limits::EwmaRateLimiter::hit", - }) + /// The new rate is stored before the comparison, so a limited hit still shapes the next + /// one; a key whose entry is older than its `ceil(period / 1000)` s TTL starts fresh. + pub fn hit(&self, key: &str, step: u64, config: &RateLimitConfig) -> Result { + let now = self.clock.now_ms(); + let ttl_ms = config.period_ms.div_ceil(1000).saturating_mul(1000); + + let mut store = self + .entries + .lock() + .map_err(|error| RelayError::RateLimiter(format!("ewma store: {error}")))?; + + // The key's TTL is `ceil(period / 1000)` seconds from its last write: at that + // instant Redis has dropped it, so the entry is gone too. + let previous = store + .get(key) + .copied() + .filter(|entry| now.saturating_sub(entry.at_ms) < ttl_ms); + + let (old_rate, old_at) = previous.map_or((0.0, now), |entry| (entry.rate, entry.at_ms)); + let delta_t = now.saturating_sub(old_at) as f64; + let lambda = std::f64::consts::LN_2 / config.period_ms as f64; + let rate = old_rate * (-lambda * delta_t).exp() + step as f64; + + store.insert(key.to_owned(), EwmaEntry { rate, at_ms: now }); + + Ok(rate > config.rate as f64) + } + + /// The stored rate for a key, after applying the TTL. + pub fn rate(&self, key: &str, config: &RateLimitConfig) -> Option { + let now = self.clock.now_ms(); + let ttl_ms = config.period_ms.div_ceil(1000).saturating_mul(1000); + + self.entries + .lock() + .ok() + .and_then(|store| store.get(key).copied()) + .filter(|entry| now.saturating_sub(entry.at_ms) < ttl_ms) + .map(|entry| entry.rate) + } +} + +/// `IRateLimiter`: one window, one verdict. +pub trait Limiter: Send + Sync + std::fmt::Debug { + /// Counts one hit and reports whether it is limited. + fn hit(&self, key: &str, step: u64, config: &RateLimitConfig) -> Result; + + /// Which store is running. + fn strategy(&self) -> RateLimiterStrategy; +} + +impl Limiter for SlidingWindowRateLimiter { + fn hit(&self, key: &str, step: u64, config: &RateLimitConfig) -> Result { + SlidingWindowRateLimiter::hit(self, key, step, config) + } + + fn strategy(&self) -> RateLimiterStrategy { + RateLimiterStrategy::SlidingWindow + } +} + +impl Limiter for EwmaRateLimiter { + fn hit(&self, key: &str, step: u64, config: &RateLimitConfig) -> Result { + EwmaRateLimiter::hit(self, key, step, config) + } + + fn strategy(&self) -> RateLimiterStrategy { + RateLimiterStrategy::Ewma + } +} + +/// The selected limiter (`rateLimiterFactory`: `ewma` unless the settings say +/// `sliding_window`). +#[derive(Debug)] +pub enum RateLimiter { + Ewma(EwmaRateLimiter), + SlidingWindow(SlidingWindowRateLimiter), +} + +impl RateLimiter { + /// The limiter `limits.rateLimiter.strategy` selects, `ewma` by default. + pub fn from_settings(settings: &Settings, clock: Arc) -> Self { + let strategy = settings + .limits + .as_ref() + .and_then(|limits| limits.rate_limiter.as_ref()) + .map_or(RateLimiterStrategy::Ewma, |limiter| limiter.strategy); + + match strategy { + RateLimiterStrategy::SlidingWindow => { + Self::SlidingWindow(SlidingWindowRateLimiter::new(clock)) + } + RateLimiterStrategy::Ewma => Self::Ewma(EwmaRateLimiter::new(clock)), + } + } + + /// `IRateLimiter.hit(key, step, {period, rate})`: whether the hit is limited. + pub fn hit(&self, key: &str, step: u64, config: &RateLimitConfig) -> Result { + match self { + Self::Ewma(limiter) => limiter.hit(key, step, config), + Self::SlidingWindow(limiter) => limiter.hit(key, step, config), + } + } + + /// Which store is running, for logging and tests. + pub fn strategy(&self) -> RateLimiterStrategy { + match self { + Self::Ewma(_) => RateLimiterStrategy::Ewma, + Self::SlidingWindow(_) => RateLimiterStrategy::SlidingWindow, + } + } +} + +impl Limiter for RateLimiter { + fn hit(&self, key: &str, step: u64, config: &RateLimitConfig) -> Result { + RateLimiter::hit(self, key, step, config) + } + + fn strategy(&self) -> RateLimiterStrategy { + RateLimiter::strategy(self) + } +} + +/// The four wired levels (connection, message, event, invite) over one limiter. +#[derive(Debug)] +pub struct RateLimits { + limiter: Arc, + connection: Option, + message: Option, + event: Option, + invite: Option, +} + +impl RateLimits { + /// Reads every level from settings. The limiter is shared by all four, as the + /// capture's `rateLimiterFactory` singleton is. + pub fn new(settings: &Settings, clock: Arc) -> Self { + Self::with_limiter( + Arc::new(RateLimiter::from_settings(settings, clock)), + settings, + ) + } + + /// The four levels over an explicit limiter, which is how the store's failure path is + /// exercised. + pub fn with_limiter(limiter: Arc, settings: &Settings) -> Self { + let limits = settings.limits.as_ref(); + + Self { + limiter, + connection: limits.and_then(|limits| limits.connection.clone()), + message: limits.and_then(|limits| limits.message.clone()), + event: limits.and_then(|limits| limits.event.clone()), + invite: limits.and_then(|limits| limits.invite.clone()), + } + } + + /// The store the settings selected. + pub fn strategy(&self) -> RateLimiterStrategy { + self.limiter.strategy() + } + + /// `limits.connection.rateLimits` with its `ipWhitelist` bypass + /// (`rate-limiter-middleware.ts:26-53`). Every window is counted, and any trip refuses + /// the connection. + pub fn connection_limited(&self, address: &str) -> Result { + let Some(connection) = &self.connection else { + return Ok(false); + }; + + if connection + .ip_whitelist + .as_ref() + .is_some_and(|whitelist| whitelist.iter().any(|entry| entry == address)) + { + return Ok(false); + } + + let mut limited = false; + + for window in &connection.rate_limits { + let config = RateLimitConfig::from(window); + let key = format!("{address}:connection:{}", config.period_ms); + + if self.limiter.hit(&key, 1, &config)? { + limited = true; + } + } + + Ok(limited) + } + + /// `limits.message.rateLimits` with its `ipWhitelist` bypass + /// (`web-socket-adapter.ts:264-282`). `Ok(Some(backoff))` is the message not being + /// handled; `Ok(None)` is allowed. + pub fn message_backoff(&self, address: &str) -> Result, RelayError> { + let Some(message) = &self.message else { + return Ok(None); + }; + + let Some(rate_limits) = message + .rate_limits + .as_ref() + .filter(|limits| !limits.is_empty()) + else { + return Ok(None); + }; + + if message + .ip_whitelist + .as_ref() + .is_some_and(|whitelist| whitelist.iter().any(|entry| entry == address)) + { + return Ok(None); + } + + for window in rate_limits { + let config = RateLimitConfig::from(window); + let key = format!("{address}:message:{}", config.period_ms); + + if self.limiter.hit(&key, 1, &config)? { + return Ok(Some(backoff_ms(&config))); + } + } + + Ok(None) + } + + /// `limits.event.rateLimits` (`event-message-handler.ts:376-443`): + /// `:events:` or `:events::[]`, the relay's own + /// pubkey and both whitelists bypass it, and a window whose `kinds` do not match the + /// event is skipped. + pub fn event_backoff( + &self, + event: &Event, + remote_address: &str, + relay_pubkey: Option<&Pubkey>, + ) -> Result, RelayError> { + let Some(limits) = &self.event else { + return Ok(None); + }; + + let Some(rate_limits) = limits + .rate_limits + .as_ref() + .filter(|limits| !limits.is_empty()) + else { + return Ok(None); + }; + + if relay_pubkey.is_some_and(|pubkey| *pubkey == event.pubkey) { + return Ok(None); + } + + if let Some(whitelist) = &limits.whitelists { + if whitelist.pubkeys.as_ref().is_some_and(|pubkeys| { + pubkeys + .iter() + .any(|pubkey| pubkey.0 == event.pubkey.to_string()) + }) { + return Ok(None); + } + + if whitelist + .ip_addresses + .as_ref() + .is_some_and(|addresses| addresses.iter().any(|address| address == remote_address)) + { + return Ok(None); + } + } + + for window in rate_limits { + if let Some(kinds) = &window.kinds + && !kinds + .iter() + .any(|selector| kind_selector_matches(*selector, event.kind)) + { + continue; + } + + let config = RateLimitConfig::from(window); + let key = match &window.kinds { + Some(kinds) => format!( + "{}:events:{}:{}", + event.pubkey, + config.period_ms, + kind_list(kinds) + ), + None => format!("{}:events:{}", event.pubkey, config.period_ms), + }; + + if self.limiter.hit(&key, 1, &config)? { + return Ok(Some(backoff_ms(&config))); + } + } + + Ok(None) } + + /// `limits.invite.rateLimits` (`subscribe-message-handler.ts:179-206`): one window per + /// requester, `:invites:`. + pub fn invite_limited(&self, pubkey: &Pubkey) -> Result { + let Some(invite) = &self.invite else { + return Ok(false); + }; + + let Some(rate_limits) = invite + .rate_limits + .as_ref() + .filter(|limits| !limits.is_empty()) + else { + return Ok(false); + }; + + for window in rate_limits { + let config = RateLimitConfig::from(window); + let key = format!("{pubkey}:invites:{}", config.period_ms); + + if self.limiter.hit(&key, 1, &config)? { + return Ok(true); + } + } + + Ok(false) + } +} + +/// `Math.max(1, Math.ceil(period))`, the NIP-01 backoff the limited frames carry. +pub fn backoff_ms(config: &RateLimitConfig) -> u64 { + config.period_ms.max(1) +} + +/// `isEventKindOrRangeMatch`: a bare kind, or an inclusive `[first, last]` range. +pub fn kind_selector_matches(selector: KindSelector, kind: nostream_core::Kind) -> bool { + match selector { + KindSelector::Single(candidate) => candidate == kind, + KindSelector::Range([first, last]) => kind >= first && kind <= last, + } +} + +/// The capture's `toString(kinds)`: `[1,2]`, or `[[10000,19999],1]` for ranges. +fn kind_list(kinds: &[KindSelector]) -> String { + let elements: Vec = kinds + .iter() + .map(|selector| match selector { + KindSelector::Single(kind) => kind.0.to_string(), + KindSelector::Range([first, last]) => format!("[{},{}]", first.0, last.0), + }) + .collect(); + + format!("[{}]", elements.join(",")) } diff --git a/crates/nostream-relay/src/relay.rs b/crates/nostream-relay/src/relay.rs new file mode 100644 index 000000000..5062f2de7 --- /dev/null +++ b/crates/nostream-relay/src/relay.rs @@ -0,0 +1,532 @@ +//! The relay: the listening loop, one task per socket, live dispatch, liveness and drain. +//! Mirrors `src/adapters/web-socket-server-adapter.ts`, the message half of +//! `src/adapters/web-socket-adapter.ts`, and `src/factories/message-handler-factory.ts`. +//! +//! Each connection task owns its socket and the state machine R26 lists: `alive`, the +//! subscription table, the NIP-42 session, the in-flight handler set and the sink. Outbound +//! frames travel through [`FrameSink`]; inbound text is rate-limited, decoded, and handed to +//! the handler `messageHandlerFactory` would pick. Accepted events fan out to this process's +//! connections through one broadcast channel (cross-worker fan-out stays a stub). + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::broadcast; +use tokio::task::JoinSet; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; + +use nostream_config::settings::Settings; +use nostream_core::adaptive_pow::AdaptivePowState; +use nostream_core::clock::Clock; +use nostream_core::lifecycle::ShutdownState; +use nostream_core::messages::RelayMessage; +use nostream_core::{Event, messages}; + +use crate::connection::{AcceptedTransport, Connection, ConnectionContext}; +use crate::error::RelayError; +use crate::handlers::{self, HandlerContext, Stores}; +use crate::hidden_content::{DEFAULT_REFRESH_INTERVAL, HiddenContentCache}; +use crate::limits::RateLimits; +use crate::relay_key::RelayKeySource; +use crate::sink::FrameSink; +use crate::transport; + +/// The knobs a [`Relay`] is built with that are not settings (`liveness` and the NIP-56 +/// refresh are intervals the capture fixes in code, and tests shorten both). +#[derive(Debug, Clone)] +pub struct RelayConfig { + /// `WSS_CLIENT_HEALTH_PROBE_INTERVAL` (`web-socket-server-adapter.ts:18`). + pub liveness_interval: Duration, + /// `DEFAULT_REFRESH_INTERVAL_MS` (`hidden-content-cache.ts:7`). + pub hidden_refresh_interval: Duration, + /// `WS_DRAIN_TIMEOUT_MS`, resolved by `getWsDrainTimeoutMs`. + pub drain_timeout: Duration, +} + +impl Default for RelayConfig { + fn default() -> Self { + Self { + liveness_interval: Duration::from_millis(120_000), + hidden_refresh_interval: DEFAULT_REFRESH_INTERVAL, + drain_timeout: transport::drain_timeout(), + } + } +} + +/// The relay's shared state: one value handed to every connection task. +pub struct Relay { + settings: Arc, + stores: Arc, + clock: Arc, + keys: Arc, + limits: Arc, + hidden: Arc, + hub: broadcast::Sender, + pow: Arc>, + config: RelayConfig, +} + +impl std::fmt::Debug for Relay { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Relay") + .field("relay_url", &self.settings.info.relay_url) + .field("config", &self.config) + .finish_non_exhaustive() + } +} + +impl Relay { + /// Builds the relay from the loaded settings, the repository seams and a clock. The + /// signing key is read from the environment (`RELAY_PRIVATE_KEY`, then `SECRET`). + pub fn new(settings: Settings, stores: Arc, clock: Arc) -> Arc { + Self::with_keys(settings, stores, clock, RelayKeySource::from_env()) + } + + /// Builds the relay with an explicit key source, for tests and for the daemon's wiring. + pub fn with_keys( + settings: Settings, + stores: Arc, + clock: Arc, + keys: RelayKeySource, + ) -> Arc { + let settings = Arc::new(settings); + let (hub, _receiver) = broadcast::channel(1_024); + + Arc::new(Self { + limits: Arc::new(RateLimits::new(&settings, Arc::clone(&clock))), + settings, + stores, + clock, + keys: Arc::new(keys), + hidden: HiddenContentCache::new(), + hub, + pow: Arc::new(Mutex::new(AdaptivePowState::default())), + config: RelayConfig::default(), + }) + } + + /// Replaces the intervals, returning the relay for chaining. + pub fn with_config(mut self: Arc, config: RelayConfig) -> Arc { + Arc::get_mut(&mut self) + .map(|relay| relay.config = config) + .unwrap_or_else(|| { + tracing::warn!("relay is already shared; the intervals were not replaced"); + }); + + self + } + + /// The intervals this relay runs with. + pub fn config(&self) -> &RelayConfig { + &self.config + } + + /// The hidden-content mirror, so a caller can seed it. + pub fn hidden_content(&self) -> &Arc { + &self.hidden + } + + /// The process's broadcast hub, for a caller that publishes from outside a connection. + pub fn hub(&self) -> &broadcast::Sender { + &self.hub + } + + /// The context one socket's handlers run in. + fn handler_context(&self, connection: Arc, sink: FrameSink) -> HandlerContext { + HandlerContext { + settings: Arc::clone(&self.settings), + clock: Arc::clone(&self.clock), + sink, + connection, + stores: Arc::clone(&self.stores), + limits: Arc::clone(&self.limits), + hidden: Arc::clone(&self.hidden), + keys: Arc::clone(&self.keys), + broadcast: self.hub.clone(), + pow: Arc::clone(&self.pow), + } + } + + /// Accepts sockets until `shutdown` fires, then drains. + /// + /// The hidden-content refresh starts with the relay and stops with it; the drain waits + /// `config.drain_timeout` for the connections to finish and then aborts the rest + /// (`drainClients`, `web-socket-server-adapter.ts:67-95`). + pub async fn serve( + self: &Arc, + listener: TcpListener, + shutdown: ShutdownState, + ) -> Result<(), RelayError> { + let hidden_task = self.hidden.start( + Arc::clone(&self.stores.reports), + Arc::clone(&self.settings), + self.config.hidden_refresh_interval, + Arc::clone(&self.clock), + ); + + let mut connections: JoinSet<()> = JoinSet::new(); + + loop { + tokio::select! { + accepted = listener.accept() => { + match accepted { + Ok((stream, peer)) => { + let relay = Arc::clone(self); + let shutdown = shutdown.clone(); + + connections.spawn(async move { + relay.handle_socket(stream, peer, shutdown).await; + }); + } + Err(error) => { + tracing::warn!(error = %error, "accept failed"); + } + } + } + _ = shutdown.wait_for_drain() => break, + Some(_) = connections.join_next(), if !connections.is_empty() => {} + } + } + + tracing::debug!( + connections = connections.len(), + timeout_ms = self.config.drain_timeout.as_millis(), + "draining" + ); + + let deadline = tokio::time::sleep(self.config.drain_timeout); + tokio::pin!(deadline); + + while !connections.is_empty() { + tokio::select! { + _ = connections.join_next() => {} + _ = &mut deadline => { + tracing::warn!( + remaining = connections.len(), + "terminating connections after the drain timeout" + ); + connections.abort_all(); + break; + } + } + } + + hidden_task.abort(); + + Ok(()) + } + + /// Serves one accepted socket: the upgrade, the per-socket state machine, and the close. + pub async fn handle_socket( + self: Arc, + stream: TcpStream, + peer: std::net::SocketAddr, + shutdown: ShutdownState, + ) { + let max_payload = transport::max_payload_size(&self.settings); + + let transport = match transport::upgrade(stream, max_payload).await { + Ok(transport) => transport, + Err(error) => { + tracing::debug!(%peer, error = %error, "websocket handshake failed"); + return; + } + }; + + if shutdown.is_draining() { + tracing::debug!(%peer, "client rejected: draining"); + self.close_draining(transport).await; + return; + } + + if self.is_connection_rate_limited(&peer).await { + tracing::debug!(%peer, "client terminated: rate-limited"); + return; + } + + self.run_connection(transport, peer, shutdown).await; + } + + /// `isRateLimited` at the connection level (`rate-limiter-middleware.ts`): a tripped + /// window terminates the upgrade. + async fn is_connection_rate_limited(&self, peer: &std::net::SocketAddr) -> bool { + match self.limits.connection_limited(&peer.ip().to_string()) { + Ok(limited) => limited, + Err(error) => { + // Fail closed: an unavailable store must not admit an unlimited client. + tracing::warn!(error = %error, "connection rate limiter unavailable; refusing"); + true + } + } + } + + async fn close_draining(&self, mut transport: AcceptedTransport) { + let _ = transport + .close(Some(transport::close_frame( + CloseCode::Away, + transport::DRAIN_REASON, + ))) + .await; + } + + /// The per-socket loop (R26): read, live-dispatch, liveness and drain in one task. + async fn run_connection( + self: Arc, + mut transport: AcceptedTransport, + peer: std::net::SocketAddr, + shutdown: ShutdownState, + ) { + let (sink, mut frames) = FrameSink::channel(); + + let connection_context = ConnectionContext { + settings: Arc::clone(&self.settings), + clock: Arc::clone(&self.clock), + sink: sink.clone(), + }; + + let connection = match Connection::accept(&transport, &connection_context) { + Ok(connection) => Arc::new(connection), + Err(error) => { + tracing::warn!(%peer, error = %error, "unable to register the connection"); + return; + } + }; + + let context = Arc::new(self.handler_context(Arc::clone(&connection), sink.clone())); + + let mut hub = self.hub.subscribe(); + let mut drained = shutdown.watch(); + // `tokio::time::interval`'s first tick is immediate; the capture's `setInterval` + // first fires one interval after the socket opens. + let mut liveness = tokio::time::interval_at( + tokio::time::Instant::now() + self.config.liveness_interval, + self.config.liveness_interval, + ); + let mut alive = true; + let mut handlers: JoinSet<()> = JoinSet::new(); + + // The task keeps a sender alive so the frame queue is never closed underneath it. + let _task_sink = sink.clone(); + + loop { + tokio::select! { + frame = frames.recv() => { + let Some(text) = frame else { break }; + + if transport.send(Message::Text(text.into())).await.is_err() { + break; + } + } + message = transport.next() => { + let Some(message) = message else { break }; + + match message { + Ok(Message::Text(text)) => { + alive = true; + self.on_client_message(text.as_str(), &context, &mut handlers); + } + Ok(Message::Binary(_)) => { + alive = true; + } + Ok(Message::Ping(payload)) => { + // A client Ping is answered in place and marks the socket alive + // (`web-socket-adapter.ts:342-346`). + alive = true; + + if transport.send(Message::Pong(payload)).await.is_err() { + break; + } + } + Ok(Message::Pong(_)) => { + alive = true; + } + Ok(Message::Close(_)) => break, + Ok(Message::Frame(_)) => {} + Err(error) => { + self.close_on_error(&mut transport, &error).await; + break; + } + } + } + event = hub.recv() => { + if let Ok(event) = event { + self.dispatch_live(&context, &event); + } + } + _ = liveness.tick() => { + if !alive { + // No pong and no message since the previous tick: close with no code + // (`web-socket-adapter.ts:157-167`). + tracing::debug!(%peer, "pong timeout"); + break; + } + + alive = false; + + if transport.send(Message::Ping(Vec::new().into())).await.is_err() { + break; + } + } + _ = drained.changed() => { + if *drained.borrow() { + self.drain_connection(&context, &mut transport).await; + break; + } + } + Some(_) = handlers.join_next(), if !handlers.is_empty() => {} + } + } + + handlers.abort_all(); + connection.close(); + } + + /// The close frame an inbound error implies: 1009 for a message over the limit, 1002 for + /// a protocol violation (an RSV bit with no negotiated extension), nothing otherwise. + async fn close_on_error( + &self, + transport: &mut AcceptedTransport, + error: &tokio_tungstenite::tungstenite::Error, + ) { + if transport::is_message_too_long(error) { + tracing::debug!("client sent a payload too large"); + let _ = transport.close(Some(transport::too_big_frame())).await; + } else if transport::is_protocol_error(error) { + tracing::debug!(error = %error, "websocket protocol error"); + let _ = transport + .close(Some(transport::protocol_error_frame())) + .await; + } else { + tracing::debug!(error = %error, "websocket closed"); + } + } + + /// One inbound text frame: the message limiter first, then the decoder, then the handler + /// (`web-socket-adapter.ts:198-240`). + fn on_client_message( + &self, + text: &str, + context: &Arc, + handlers: &mut JoinSet<()>, + ) { + match self + .limits + .message_backoff(&context.connection.remote_address) + { + Ok(Some(backoff)) => { + let frame = rate_limited_frame(text, backoff, &context.connection); + + context.sink.send(&frame); + return; + } + Ok(None) => {} + Err(error) => { + // The capture's adapter logs the throw and drops the message without a frame. + tracing::error!(error = %error, "message rate limiter unavailable"); + return; + } + } + + match messages::decode(text) { + Ok(message) => { + let context = Arc::clone(context); + handlers.spawn(handlers::dispatch(message, context)); + } + Err(error) => context.send_notice(&error.notice()), + } + } + + /// Live dispatch (`onSendEvent`): the read guard, the NIP-56 mirror, then every matching + /// subscription on this socket. + fn dispatch_live(&self, context: &HandlerContext, event: &Event) { + if !context.read_authorization_guard().allows(event) { + return; + } + + let hiding = self + .settings + .nip56 + .as_ref() + .is_some_and(|nipp56| nipp56.enabled && nipp56.hide_actionable_reports); + + if hiding && self.hidden.is_hidden(event) { + return; + } + + for subscription_id in context.connection.matching_subscription_ids(event) { + context + .sink + .send(&RelayMessage::Event(messages::OutgoingEventMessage { + subscription_id, + event: event.clone(), + })); + } + } + + /// `drainAndClose`: one `CLOSED` per subscription, then 1001 with the drain reason. + async fn drain_connection(&self, context: &HandlerContext, transport: &mut AcceptedTransport) { + // The frames go straight to the socket: the loop that drains the sink is the one + // being left, so a queued frame would be dropped with it. + for subscription in context.connection.subscriptions() { + let frame = RelayMessage::Closed(messages::ClosedMessage { + subscription_id: subscription.id, + message: format!("error: {}", transport::DRAIN_REASON), + params: None, + }); + + if transport + .send(Message::Text(frame.to_text().into())) + .await + .is_err() + { + return; + } + } + + let _ = transport + .close(Some(transport::close_frame( + CloseCode::Away, + transport::DRAIN_REASON, + ))) + .await; + } +} + +/// `createRateLimitedMessage` (`web-socket-adapter.ts:284-307`): a `REQ`/`COUNT` frame with +/// an id is closed with the backoff, and a limited `REQ` also ends its subscription. +fn rate_limited_frame(text: &str, backoff_ms: u64, connection: &Connection) -> RelayMessage { + let value: serde_json::Value = match serde_json::from_str(text) { + Ok(value) => value, + Err(_) => return RelayMessage::rate_limited(None, None, backoff_ms), + }; + + let serde_json::Value::Array(items) = value else { + return RelayMessage::rate_limited(None, None, backoff_ms); + }; + + let verb = items.first().and_then(serde_json::Value::as_str); + let subscription_id = items.get(1).and_then(serde_json::Value::as_str); + + match (verb, subscription_id) { + (Some("REQ"), Some(subscription_id)) => { + connection.abort_read(subscription_id); + connection.unsubscribe(subscription_id); + + RelayMessage::rate_limited( + Some(messages::MessageType::Req), + Some(subscription_id), + backoff_ms, + ) + } + (Some("COUNT"), Some(subscription_id)) => RelayMessage::rate_limited( + Some(messages::MessageType::Count), + Some(subscription_id), + backoff_ms, + ), + _ => RelayMessage::rate_limited(None, None, backoff_ms), + } +} diff --git a/crates/nostream-relay/src/relay_key.rs b/crates/nostream-relay/src/relay_key.rs new file mode 100644 index 000000000..310384f61 --- /dev/null +++ b/crates/nostream-relay/src/relay_key.rs @@ -0,0 +1,121 @@ +//! The relay's signing key. Mirrors `getRelayPrivateKey`/`deriveFromSecret` +//! (`src/utils/event.ts:142-154`, `src/utils/secret.ts`). +//! +//! `RELAY_PRIVATE_KEY`, when set, is the secret key; otherwise the key is +//! `HMAC-SHA256(SECRET, relay_url)` — the capture's `deriveFromSecret(relay_url)`. With +//! neither set the relay has no key, and every caller that needs one reports the capture's +//! outcome: an EVENT is answered `["OK",,false,"error: unable to process event"]` +//! (ADR 0004). +//! +//! Resolution is memoised on the source: the capture caches in a module-level +//! `privateKeyCache`, which is one process, and a source is one process's settings. + +use std::sync::{Arc, Mutex}; + +use hmac::{Hmac, KeyInit, Mac}; +use sha2::Sha256; + +use nostream_core::Pubkey; +use nostream_core::crypto::public_key; + +use crate::error::RelayError; + +type HmacSha256 = Hmac; + +/// The environment variables the relay key reads. `RELAY_PRIVATE_KEY` wins over `SECRET`. +#[derive(Debug, Clone, Default)] +pub struct RelayKeySource { + relay_private_key: Option, + secret: Option, + resolved: Arc>>, +} + +impl RelayKeySource { + /// Reads `RELAY_PRIVATE_KEY` and `SECRET` once. + pub fn from_env() -> Self { + Self::from_values( + std::env::var("RELAY_PRIVATE_KEY").ok(), + std::env::var("SECRET").ok(), + ) + } + + /// A source with the two values supplied, for tests and for the daemon's composition. + pub fn from_values(relay_private_key: Option, secret: Option) -> Self { + Self { + relay_private_key: relay_private_key.filter(|value| !value.is_empty()), + secret: secret.filter(|value| !value.is_empty()), + resolved: Arc::new(Mutex::new(None)), + } + } + + /// The relay's key pair, or [`RelayError::Configuration`] when neither source is set. + pub fn resolve(&self, relay_url: &str) -> Result { + if let Some(keys) = *self + .resolved + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + { + return Ok(keys); + } + + let key = match (&self.relay_private_key, &self.secret) { + (Some(private_key), _) => parse_secret_key(private_key)?, + (None, Some(secret)) => derive_from_secret(secret, relay_url)?, + (None, None) => { + return Err(RelayError::Configuration( + "SECRET environment variable not set".to_owned(), + )); + } + }; + + let keys = RelayKeys { + secret_key: key, + public_key: public_key(&key) + .map_err(|error| RelayError::Configuration(error.to_string()))?, + }; + + *self + .resolved + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(keys); + + Ok(keys) + } + + /// The relay's public key, or `None` when no key source is configured. This is the + /// capture's `tryGetRelayNip43Pubkey` shape: callers that only need the identity (the + /// admission and NIP-05 bypasses, the rate-limit bypass) do not fail without a key. + pub fn try_public_key(&self, relay_url: &str) -> Option { + self.resolve(relay_url).ok().map(|keys| keys.public_key) + } +} + +/// The relay's signing key pair. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RelayKeys { + pub secret_key: [u8; 32], + pub public_key: Pubkey, +} + +fn parse_secret_key(value: &str) -> Result<[u8; 32], RelayError> { + let bytes = hex::decode(value).map_err(|error| { + RelayError::Configuration(format!("RELAY_PRIVATE_KEY is not hexadecimal: {error}")) + })?; + + bytes.try_into().map_err(|bytes: Vec| { + RelayError::Configuration(format!( + "RELAY_PRIVATE_KEY must be 32 bytes, got {}", + bytes.len() + )) + }) +} + +/// `HMAC-SHA256(SECRET, purpose)` (`hmacSha256`, `src/utils/secret.ts:10-13`). +fn derive_from_secret(secret: &str, purpose: &str) -> Result<[u8; 32], RelayError> { + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) + .map_err(|error| RelayError::Configuration(format!("SECRET: {error}")))?; + + mac.update(purpose.as_bytes()); + + Ok(mac.finalize().into_bytes().into()) +} diff --git a/crates/nostream-relay/src/sink.rs b/crates/nostream-relay/src/sink.rs new file mode 100644 index 000000000..72a94581e --- /dev/null +++ b/crates/nostream-relay/src/sink.rs @@ -0,0 +1,53 @@ +//! The one write point for server→client frames. Mirrors `WebSocketAdapter.sendMessage` +//! (`src/adapters/web-socket-adapter.ts:150-155`). +//! +//! Every frame the relay sends — handler replies, subscription reads, live dispatch, drain +//! notices, the NIP-42 challenge — goes through [`FrameSink`]. Handlers and strategies hold +//! a sink and never touch the transport; the connection task is the only writer, and it +//! encodes through [`RelayMessage::to_text`], so frame encoding happens in one place. +//! +//! A send on a socket that is no longer open is dropped silently, which is what the +//! capture's `readyState !== OPEN` guard does: it returns without an error. + +use tokio::sync::mpsc; + +use nostream_core::messages::RelayMessage; + +/// A handle to one connection's outbound frame queue. +#[derive(Debug, Clone)] +pub struct FrameSink { + sender: Option>, +} + +impl FrameSink { + /// A sink whose frames go to `sender`. + pub fn new(sender: mpsc::UnboundedSender) -> Self { + Self { + sender: Some(sender), + } + } + + /// Creates the queue and its sink. The receiver is drained by the connection task. + pub fn channel() -> (Self, mpsc::UnboundedReceiver) { + let (sender, receiver) = mpsc::unbounded_channel(); + + (Self::new(sender), receiver) + } + + /// A sink that is already closed: every send is dropped silently. Used by tests that + /// assert the "not open" behaviour without a socket. + pub fn closed() -> Self { + Self { sender: None } + } + + /// Sends one frame. A closed socket drops the frame and is not an error. + pub fn send(&self, message: &RelayMessage) { + let Some(sender) = &self.sender else { + return; + }; + + // The receiver is gone exactly when the connection task ended: the socket is not + // open, and the capture's guard returns without sending. + let _ = sender.send(message.to_text()); + } +} diff --git a/crates/nostream-relay/src/strategies/contact_list.rs b/crates/nostream-relay/src/strategies/contact_list.rs index 952668b95..c73852d0d 100644 --- a/crates/nostream-relay/src/strategies/contact_list.rs +++ b/crates/nostream-relay/src/strategies/contact_list.rs @@ -3,10 +3,10 @@ use nostream_core::Event; use crate::error::RelayError; -use crate::strategies::StrategyOutcome; +use crate::handlers::HandlerContext; -/// Phase 1 stub — job 005; will decide acceptance for NIP-02 contact lists; see `src/handlers/event-strategies/contact-list-event-strategy.ts`. -pub async fn handle(_event: &Event) -> Result { +/// Phase 2 stub — job 009; will upsert a kind-3 contact list and refresh the WoT graph (NIP-02); see `src/handlers/event-strategies/contact-list-event-strategy.ts`. +pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { Err(RelayError::NotImplemented { module: "relay::strategies::contact_list", }) diff --git a/crates/nostream-relay/src/strategies/default.rs b/crates/nostream-relay/src/strategies/default.rs index cb0dc7bda..7e3ea77a7 100644 --- a/crates/nostream-relay/src/strategies/default.rs +++ b/crates/nostream-relay/src/strategies/default.rs @@ -1,13 +1,50 @@ //! The `default` event strategy. Mirrors `src/handlers/event-strategies/default-event-strategy.ts`. +//! +//! `EventRepository::create`, then `["OK",,true,""]` when a row was inserted or +//! `["OK",,true,"duplicate:"]` when it was not, then a broadcast **only** on insert. + +use std::future::Future; +use std::pin::Pin; use nostream_core::Event; use crate::error::RelayError; -use crate::strategies::StrategyOutcome; +use crate::handlers::HandlerContext; +use crate::strategies::EventStrategy; + +/// The default arm: kinds with no dedicated strategy. +#[derive(Debug, Clone, Copy, Default)] +pub struct DefaultStrategy; + +impl EventStrategy for DefaultStrategy { + fn execute<'a>( + &'a self, + event: &'a Event, + context: &'a HandlerContext, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + let record = nostream_storage::repositories::EventRecord { + row_id: None, + event: event.clone(), + first_seen: None, + deleted_at: None, + expires_at: nostream_core::expiration::get_event_expiration(event).map(i64::from), + event_deduplication: None, + remote_address: context.connection.remote_address.parse().ok(), + }; + + let count = context.stores.events.create(&record).await?; + + let message = if count > 0 { "" } else { "duplicate:" }; + + context.send_ok(event.id, true, message, None); + + if count > 0 { + // A closed hub means no connection is listening; the event is still stored. + let _ = context.broadcast.send(event.clone()); + } -/// Phase 1 stub — job 005; will decide acceptance for kinds with no dedicated strategy; see `src/handlers/event-strategies/default-event-strategy.ts`. -pub async fn handle(_event: &Event) -> Result { - Err(RelayError::NotImplemented { - module: "relay::strategies::default", - }) + Ok(()) + }) + } } diff --git a/crates/nostream-relay/src/strategies/delete.rs b/crates/nostream-relay/src/strategies/delete.rs index c8054602d..b29aad1a3 100644 --- a/crates/nostream-relay/src/strategies/delete.rs +++ b/crates/nostream-relay/src/strategies/delete.rs @@ -3,10 +3,10 @@ use nostream_core::Event; use crate::error::RelayError; -use crate::strategies::StrategyOutcome; +use crate::handlers::HandlerContext; -/// Phase 1 stub — job 005; will decide acceptance for NIP-09 deletions; see `src/handlers/event-strategies/delete-event-strategy.ts`. -pub async fn handle(_event: &Event) -> Result { +/// Phase 2 stub — job 009; will soft-delete the referenced events (NIP-09); see `src/handlers/event-strategies/delete-event-strategy.ts`. +pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { Err(RelayError::NotImplemented { module: "relay::strategies::delete", }) diff --git a/crates/nostream-relay/src/strategies/dvm_job_request.rs b/crates/nostream-relay/src/strategies/dvm_job_request.rs index f5ac16d1b..f67e5fb42 100644 --- a/crates/nostream-relay/src/strategies/dvm_job_request.rs +++ b/crates/nostream-relay/src/strategies/dvm_job_request.rs @@ -3,10 +3,10 @@ use nostream_core::Event; use crate::error::RelayError; -use crate::strategies::StrategyOutcome; +use crate::handlers::HandlerContext; -/// Phase 1 stub — job 005; will decide acceptance for NIP-90 job requests; see `src/handlers/event-strategies/dvm-job-request-event-strategy.ts`. -pub async fn handle(_event: &Event) -> Result { +/// Phase 2 stub — job 009; will store a job row for a NIP-90 request; see `src/handlers/event-strategies/dvm-job-request-event-strategy.ts`. +pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { Err(RelayError::NotImplemented { module: "relay::strategies::dvm_job_request", }) diff --git a/crates/nostream-relay/src/strategies/ephemeral.rs b/crates/nostream-relay/src/strategies/ephemeral.rs index c7fe05eab..f01d875c6 100644 --- a/crates/nostream-relay/src/strategies/ephemeral.rs +++ b/crates/nostream-relay/src/strategies/ephemeral.rs @@ -3,10 +3,10 @@ use nostream_core::Event; use crate::error::RelayError; -use crate::strategies::StrategyOutcome; +use crate::handlers::HandlerContext; -/// Phase 1 stub — job 005; will decide acceptance for kinds 20000-29999; see `src/handlers/event-strategies/ephemeral-event-strategy.ts`. -pub async fn handle(_event: &Event) -> Result { +/// Phase 2 stub — job 009; will answer OK and broadcast without storing; see `src/handlers/event-strategies/ephemeral-event-strategy.ts`. +pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { Err(RelayError::NotImplemented { module: "relay::strategies::ephemeral", }) diff --git a/crates/nostream-relay/src/strategies/gift_wrap.rs b/crates/nostream-relay/src/strategies/gift_wrap.rs index f8203250b..35fba7521 100644 --- a/crates/nostream-relay/src/strategies/gift_wrap.rs +++ b/crates/nostream-relay/src/strategies/gift_wrap.rs @@ -3,10 +3,10 @@ use nostream_core::Event; use crate::error::RelayError; -use crate::strategies::StrategyOutcome; +use crate::handlers::HandlerContext; -/// Phase 1 stub — job 005; will decide acceptance for NIP-59 gift wraps; see `src/handlers/event-strategies/gift-wrap-event-strategy.ts`. -pub async fn handle(_event: &Event) -> Result { +/// Phase 2 stub — job 009; will validate the p tag and the NIP-44 payload before storing a kind-1059 gift wrap; see `src/handlers/event-strategies/gift-wrap-event-strategy.ts`. +pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { Err(RelayError::NotImplemented { module: "relay::strategies::gift_wrap", }) diff --git a/crates/nostream-relay/src/strategies/group.rs b/crates/nostream-relay/src/strategies/group.rs index dadd5bf56..bbdf1278f 100644 --- a/crates/nostream-relay/src/strategies/group.rs +++ b/crates/nostream-relay/src/strategies/group.rs @@ -3,10 +3,10 @@ use nostream_core::Event; use crate::error::RelayError; -use crate::strategies::StrategyOutcome; +use crate::handlers::HandlerContext; -/// Phase 1 stub — job 005; will decide acceptance for Marmot group events; see `src/handlers/event-strategies/group-event-strategy.ts`. -pub async fn handle(_event: &Event) -> Result { +/// Phase 2 stub — job 009; will validate the h tag on a Marmot kind-445 group event; see `src/handlers/event-strategies/group-event-strategy.ts`. +pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { Err(RelayError::NotImplemented { module: "relay::strategies::group", }) diff --git a/crates/nostream-relay/src/strategies/invite_request.rs b/crates/nostream-relay/src/strategies/invite_request.rs index 518d67651..54bea5ad0 100644 --- a/crates/nostream-relay/src/strategies/invite_request.rs +++ b/crates/nostream-relay/src/strategies/invite_request.rs @@ -3,10 +3,10 @@ use nostream_core::Event; use crate::error::RelayError; -use crate::strategies::StrategyOutcome; +use crate::handlers::HandlerContext; -/// Phase 1 stub — job 005; will decide acceptance for NIP-43 invite requests; see `src/handlers/event-strategies/invite-request-event-strategy.ts`. -pub async fn handle(_event: &Event) -> Result { +/// Phase 2 stub — job 009; will answer a published kind-28935 with "use a REQ instead"; see `src/handlers/event-strategies/invite-request-event-strategy.ts`. +pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { Err(RelayError::NotImplemented { module: "relay::strategies::invite_request", }) diff --git a/crates/nostream-relay/src/strategies/join_request.rs b/crates/nostream-relay/src/strategies/join_request.rs index 9e63f04d0..d4f791db6 100644 --- a/crates/nostream-relay/src/strategies/join_request.rs +++ b/crates/nostream-relay/src/strategies/join_request.rs @@ -3,10 +3,10 @@ use nostream_core::Event; use crate::error::RelayError; -use crate::strategies::StrategyOutcome; +use crate::handlers::HandlerContext; -/// Phase 1 stub — job 005; will decide acceptance for kind-28934 join requests; see `src/handlers/event-strategies/join-request-event-strategy.ts`. -pub async fn handle(_event: &Event) -> Result { +/// Phase 2 stub — job 009; will claim an invite code and admit a member (NIP-43); see `src/handlers/event-strategies/join-request-event-strategy.ts`. +pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { Err(RelayError::NotImplemented { module: "relay::strategies::join_request", }) diff --git a/crates/nostream-relay/src/strategies/leave_request.rs b/crates/nostream-relay/src/strategies/leave_request.rs index dada4fa2e..0250ad001 100644 --- a/crates/nostream-relay/src/strategies/leave_request.rs +++ b/crates/nostream-relay/src/strategies/leave_request.rs @@ -3,10 +3,10 @@ use nostream_core::Event; use crate::error::RelayError; -use crate::strategies::StrategyOutcome; +use crate::handlers::HandlerContext; -/// Phase 1 stub — job 005; will decide acceptance for kind-28935 leave requests; see `src/handlers/event-strategies/leave-request-event-strategy.ts`. -pub async fn handle(_event: &Event) -> Result { +/// Phase 2 stub — job 009; will revoke a member (NIP-43); see `src/handlers/event-strategies/leave-request-event-strategy.ts`. +pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { Err(RelayError::NotImplemented { module: "relay::strategies::leave_request", }) diff --git a/crates/nostream-relay/src/strategies/mod.rs b/crates/nostream-relay/src/strategies/mod.rs index 0162f688a..cc16a2fdc 100644 --- a/crates/nostream-relay/src/strategies/mod.rs +++ b/crates/nostream-relay/src/strategies/mod.rs @@ -1,5 +1,10 @@ -//! The event strategies. Mirrors `src/handlers/event-strategies/*-event-strategy.ts` at the -//! capture commit: 15 strategies, one module each. +//! The event strategies. Mirrors `src/handlers/event-strategies/*-event-strategy.ts` and the +//! selection chain of `src/factories/event-strategy-factory.ts:56-108`. +//! +//! The trait is `IEventStrategy.execute` (one method, `(event, sink)`); the registry +//! [`select`]s the arm in the capture's order — which **is** the policy — and [`Strategy`] +//! runs it. `default` is implemented here; the other fourteen modules are labelled stubs for +//! job 009. pub mod contact_list; pub mod default; @@ -17,22 +22,253 @@ pub mod report; pub mod timestamp; pub mod vanish; -use nostream_core::Event; +use std::future::Future; +use std::pin::Pin; + +use nostream_core::{Event, Kind}; use crate::error::RelayError; +use crate::handlers::HandlerContext; + +/// One strategy's outcome (`IEventStrategy.execute`). +/// +/// `execute` writes its own frames through the context's sink, exactly as the capture's +/// strategies emit `OK`/`EVENT` messages; an `Err` is the handler's +/// `error: unable to process event`. +pub trait EventStrategy: Send + Sync { + /// Runs the strategy for `event`. + fn execute<'a>( + &'a self, + event: &'a Event, + context: &'a HandlerContext, + ) -> Pin> + Send + 'a>>; +} -/// What a strategy decided about one event. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum StrategyOutcome { - Accept, - Reject(String), +/// Which arm `eventStrategyFactory` selects, in its order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Selection { + Vanish, + GiftWrap, + Group, + Timestamp, + ContactList, + Report, + Replaceable, + Join, + Leave, + InviteRequest, + DvmJob, + Ephemeral, + Delete, + ParameterizedReplaceable, + Default, } -/// Dispatches an event to its strategy. +/// The capture's selection chain (`eventStrategyFactory`, `:56-108`). /// -/// Phase 1 stub — job 005; will pick the strategy for the kind and run it; see `src/factories/event-strategy-factory.ts`. -pub async fn dispatch(_event: &Event) -> Result { - Err(RelayError::NotImplemented { - module: "relay::strategies::dispatch", - }) +/// The order is the policy: contact lists are intercepted before the generic replaceable +/// arm, reports before the default arm, and the NIP-43 requests before the ephemeral arm +/// (kinds 28934/28935/28936 fall in the ephemeral range and must not be broadcast). +pub fn select(event: &Event) -> Selection { + if event.is_request_to_vanish(None) { + Selection::Vanish + } else if event.kind.is_gift_wrap() { + Selection::GiftWrap + } else if event.kind.is_marmot_group_event() { + Selection::Group + } else if event.kind == Kind(1040) { + Selection::Timestamp + } else if event.kind.is_contact_list() { + Selection::ContactList + } else if event.kind == Kind::REPORT { + Selection::Report + } else if event.kind == Kind(10_002) || event.kind.is_replaceable() { + Selection::Replaceable + } else if event.kind == Kind(28_934) { + Selection::Join + } else if event.kind == Kind(28_936) { + Selection::Leave + } else if event.kind == Kind(28_935) { + Selection::InviteRequest + } else if event.kind.is_dvm_job_request() { + Selection::DvmJob + } else if event.kind.is_ephemeral() { + Selection::Ephemeral + } else if event.kind.is_delete() { + Selection::Delete + } else if event.kind.is_parameterized_replaceable() { + Selection::ParameterizedReplaceable + } else { + Selection::Default + } +} + +/// The ordered registry: one stateless value that runs the selected arm. +#[derive(Debug, Clone, Copy, Default)] +pub struct Strategy; + +impl EventStrategy for Strategy { + fn execute<'a>( + &'a self, + event: &'a Event, + context: &'a HandlerContext, + ) -> Pin> + Send + 'a>> { + let selection = select(event); + + Box::pin(async move { + match selection { + Selection::Default => default::DefaultStrategy.execute(event, context).await, + Selection::Vanish => vanish::execute(event, context).await, + Selection::GiftWrap => gift_wrap::execute(event, context).await, + Selection::Group => group::execute(event, context).await, + Selection::Timestamp => timestamp::execute(event, context).await, + Selection::ContactList => contact_list::execute(event, context).await, + Selection::Report => report::execute(event, context).await, + Selection::Replaceable => replaceable::execute(event, context).await, + Selection::Join => join_request::execute(event, context).await, + Selection::Leave => leave_request::execute(event, context).await, + Selection::InviteRequest => invite_request::execute(event, context).await, + Selection::DvmJob => dvm_job_request::execute(event, context).await, + Selection::Ephemeral => ephemeral::execute(event, context).await, + Selection::Delete => delete::execute(event, context).await, + Selection::ParameterizedReplaceable => { + parameterized_replaceable::execute(event, context).await + } + } + }) + } +} + +#[cfg(test)] +mod stubs { + //! AC27: each of the fourteen arms is honestly stubbed. + + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use super::*; + use nostream_core::event::{EventId, Pubkey, Signature}; + + fn event_of(kind: u64) -> Event { + Event { + id: EventId::from_byte_array([1; 32]), + pubkey: Pubkey::from_byte_array([2; 32]), + created_at: 1_700_000_000, + kind: Kind(kind), + tags: Vec::new(), + content: String::new(), + sig: Signature::from_byte_array([0; 64]), + } + } + + #[test] + fn selection_follows_the_capture_chain() { + let cases = [ + (62, Selection::Vanish), + (1059, Selection::GiftWrap), + (445, Selection::Group), + (1040, Selection::Timestamp), + (3, Selection::ContactList), + (1984, Selection::Report), + (10002, Selection::Replaceable), + (0, Selection::Replaceable), + (41, Selection::Replaceable), + (10000, Selection::Replaceable), + (19999, Selection::Replaceable), + (28934, Selection::Join), + (28936, Selection::Leave), + (28935, Selection::InviteRequest), + (5000, Selection::DvmJob), + (5999, Selection::DvmJob), + (20000, Selection::Ephemeral), + (29999, Selection::Ephemeral), + (5, Selection::Delete), + (30000, Selection::ParameterizedReplaceable), + (39999, Selection::ParameterizedReplaceable), + (1, Selection::Default), + (7, Selection::Default), + // The capture reaches the strategy chain only after the EVENT handler rejects + // kind 22242; if it got here, the ephemeral range would claim it. + (22242, Selection::Ephemeral), + (443, Selection::Default), + ]; + + for (kind, expected) in cases { + assert_eq!(select(&event_of(kind)), expected, "kind {kind}"); + } + } + + #[test] + fn every_unfilled_arm_returns_not_implemented() { + use nostream_core::clock::SystemClock; + use std::sync::Arc; + + // The stub bodies do not read the context, so a sink that drops everything stands in + // for a socket. + let fixture = crate::testing::Fixture::new(crate::testing::settings()); + let context = fixture.anonymous_context( + Arc::new(SystemClock), + crate::relay_key::RelayKeySource::from_values(None, None), + ); + + let selections = [ + (Selection::Vanish, "relay::strategies::vanish"), + (Selection::GiftWrap, "relay::strategies::gift_wrap"), + (Selection::Group, "relay::strategies::group"), + (Selection::Timestamp, "relay::strategies::timestamp"), + (Selection::ContactList, "relay::strategies::contact_list"), + (Selection::Report, "relay::strategies::report"), + (Selection::Replaceable, "relay::strategies::replaceable"), + (Selection::Join, "relay::strategies::join_request"), + (Selection::Leave, "relay::strategies::leave_request"), + ( + Selection::InviteRequest, + "relay::strategies::invite_request", + ), + (Selection::DvmJob, "relay::strategies::dvm_job_request"), + (Selection::Ephemeral, "relay::strategies::ephemeral"), + (Selection::Delete, "relay::strategies::delete"), + ( + Selection::ParameterizedReplaceable, + "relay::strategies::parameterized_replaceable", + ), + ]; + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + for (selection, module) in selections { + let outcome = match selection { + Selection::Vanish => vanish::execute(&event_of(62), &context).await, + Selection::GiftWrap => gift_wrap::execute(&event_of(1059), &context).await, + Selection::Group => group::execute(&event_of(445), &context).await, + Selection::Timestamp => timestamp::execute(&event_of(1040), &context).await, + Selection::ContactList => contact_list::execute(&event_of(3), &context).await, + Selection::Report => report::execute(&event_of(1984), &context).await, + Selection::Replaceable => replaceable::execute(&event_of(0), &context).await, + Selection::Join => join_request::execute(&event_of(28934), &context).await, + Selection::Leave => leave_request::execute(&event_of(28936), &context).await, + Selection::InviteRequest => { + invite_request::execute(&event_of(28935), &context).await + } + Selection::DvmJob => dvm_job_request::execute(&event_of(5000), &context).await, + Selection::Ephemeral => ephemeral::execute(&event_of(20000), &context).await, + Selection::Delete => delete::execute(&event_of(5), &context).await, + Selection::ParameterizedReplaceable => { + parameterized_replaceable::execute(&event_of(30000), &context).await + } + Selection::Default => unreachable!("default is implemented"), + }; + + match outcome { + Err(RelayError::NotImplemented { module: reported }) => { + assert_eq!(reported, module, "{selection:?}"); + } + other => panic!("{selection:?} must be a NotImplemented stub, got {other:?}"), + } + } + }); + } } diff --git a/crates/nostream-relay/src/strategies/parameterized_replaceable.rs b/crates/nostream-relay/src/strategies/parameterized_replaceable.rs index 092faa7f2..d5e43dd9b 100644 --- a/crates/nostream-relay/src/strategies/parameterized_replaceable.rs +++ b/crates/nostream-relay/src/strategies/parameterized_replaceable.rs @@ -3,10 +3,10 @@ use nostream_core::Event; use crate::error::RelayError; -use crate::strategies::StrategyOutcome; +use crate::handlers::HandlerContext; -/// Phase 1 stub — job 005; will decide acceptance for kinds 30000-39999; see `src/handlers/event-strategies/parameterized-replaceable-event-strategy.ts`. -pub async fn handle(_event: &Event) -> Result { +/// Phase 2 stub — job 009; will upsert an addressable event by its d tag (NIP-33); see `src/handlers/event-strategies/parameterized-replaceable-event-strategy.ts`. +pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { Err(RelayError::NotImplemented { module: "relay::strategies::parameterized_replaceable", }) diff --git a/crates/nostream-relay/src/strategies/replaceable.rs b/crates/nostream-relay/src/strategies/replaceable.rs index 558bf82da..95e080585 100644 --- a/crates/nostream-relay/src/strategies/replaceable.rs +++ b/crates/nostream-relay/src/strategies/replaceable.rs @@ -3,10 +3,10 @@ use nostream_core::Event; use crate::error::RelayError; -use crate::strategies::StrategyOutcome; +use crate::handlers::HandlerContext; -/// Phase 1 stub — job 005; will decide acceptance for kinds 0, 3 and 10000-19999; see `src/handlers/event-strategies/replaceable-event-strategy.ts`. -pub async fn handle(_event: &Event) -> Result { +/// Phase 2 stub — job 009; will upsert a replaceable event by its precedence rule; see `src/handlers/event-strategies/replaceable-event-strategy.ts`. +pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { Err(RelayError::NotImplemented { module: "relay::strategies::replaceable", }) diff --git a/crates/nostream-relay/src/strategies/report.rs b/crates/nostream-relay/src/strategies/report.rs index 4babcb9e1..195fdcc5d 100644 --- a/crates/nostream-relay/src/strategies/report.rs +++ b/crates/nostream-relay/src/strategies/report.rs @@ -3,10 +3,10 @@ use nostream_core::Event; use crate::error::RelayError; -use crate::strategies::StrategyOutcome; +use crate::handlers::HandlerContext; -/// Phase 1 stub — job 005; will decide acceptance for NIP-56 reports; see `src/handlers/event-strategies/report-event-strategy.ts`. -pub async fn handle(_event: &Event) -> Result { +/// Phase 2 stub — job 009; will score and store a kind-1984 report (NIP-56); see `src/handlers/event-strategies/report-event-strategy.ts`. +pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { Err(RelayError::NotImplemented { module: "relay::strategies::report", }) diff --git a/crates/nostream-relay/src/strategies/timestamp.rs b/crates/nostream-relay/src/strategies/timestamp.rs index 19d09e109..ff44cfc06 100644 --- a/crates/nostream-relay/src/strategies/timestamp.rs +++ b/crates/nostream-relay/src/strategies/timestamp.rs @@ -3,10 +3,10 @@ use nostream_core::Event; use crate::error::RelayError; -use crate::strategies::StrategyOutcome; +use crate::handlers::HandlerContext; -/// Phase 1 stub — job 005; will decide acceptance for NIP-03 OpenTimestamps; see `src/handlers/event-strategies/timestamp-event-strategy.ts`. -pub async fn handle(_event: &Event) -> Result { +/// Phase 2 stub — job 009; will validate the OpenTimestamps attestation (NIP-03); see `src/handlers/event-strategies/timestamp-event-strategy.ts`. +pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { Err(RelayError::NotImplemented { module: "relay::strategies::timestamp", }) diff --git a/crates/nostream-relay/src/strategies/vanish.rs b/crates/nostream-relay/src/strategies/vanish.rs index 3b1b5202c..884b5e61e 100644 --- a/crates/nostream-relay/src/strategies/vanish.rs +++ b/crates/nostream-relay/src/strategies/vanish.rs @@ -3,10 +3,10 @@ use nostream_core::Event; use crate::error::RelayError; -use crate::strategies::StrategyOutcome; +use crate::handlers::HandlerContext; -/// Phase 1 stub — job 005; will decide acceptance for NIP-62 vanish requests; see `src/handlers/event-strategies/vanish-event-strategy.ts`. -pub async fn handle(_event: &Event) -> Result { +/// Phase 2 stub — job 009; will delete the requester's events and mark the pubkey vanished (NIP-62); see `src/handlers/event-strategies/vanish-event-strategy.ts`. +pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { Err(RelayError::NotImplemented { module: "relay::strategies::vanish", }) diff --git a/crates/nostream-relay/src/testing.rs b/crates/nostream-relay/src/testing.rs new file mode 100644 index 000000000..b8b9de4ec --- /dev/null +++ b/crates/nostream-relay/src/testing.rs @@ -0,0 +1,980 @@ +//! Test support: in-memory repository doubles and a context factory. +//! +//! The LMDB environment is still a stub (`crates/nostream-storage/src/lib.rs:17-19`), so no +//! test in this job may use a database. These doubles implement the few behaviours the +//! handlers call — insert-returning-a-row-count, stream-by-filter, count, actionable targets, +//! admission, NIP-05, invite codes — with the semantics the capture's Postgres path has. +//! +//! This module is compiled into the library (not `#[cfg(test)]`) because the integration +//! tests and `nostreamd`'s wire test link the crate as a dependency and cannot see a +//! test-only module. + +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use futures_util::StreamExt; +use futures_util::stream; +use nostream_config::settings::{ + Info, Network, Nip42Settings, Nip43Settings, Nip45Settings, Nip50Settings, Nip56Settings, + RateLimiterSettings, Settings, +}; +use nostream_core::clock::Clock; +use nostream_core::{Event, EventId, Filter, Kind, Pubkey}; +use nostream_storage::StorageError; +use nostream_storage::repositories::{ + ActionableTarget, CreateInviteCodeOptions, DvmJob, DvmJobStatus, EventPurgeCounts, EventRecord, + EventRepository, EventRetentionOptions, InviteCode, InviteCodeRepository, Nip05Verification, + Nip05VerificationRepository, QueryStream, ReconciliationItem, Report, ReportRepository, + StorageFuture, User, UserPatch, UserRepository, +}; + +use crate::connection::{Connection, Subscription}; +use crate::error::RelayError; +use crate::handlers::{HandlerContext, Stores}; +use crate::hidden_content::HiddenContentCache; +use crate::limits::RateLimits; +use crate::relay_key::RelayKeySource; + +/// A settings document with the fields the relay reads, and nothing else. +/// +/// The values that decide behaviour are the shipped defaults where they matter: no limits, +/// NIP-42/43/45/50/56 off, and `relay_url` on loopback. +pub fn settings() -> Settings { + Settings { + info: Info { + relay_url: "wss://relay.example.com".to_owned(), + name: "nostream-test".to_owned(), + description: String::new(), + banner: None, + icon: None, + pubkey: String::new(), + self_: None, + contact: String::new(), + terms_of_service: None, + }, + admin: None, + payments: None, + payments_processors: None, + network: Network::default(), + workers: None, + event_store: None, + limits: None, + mirroring: None, + dvm: None, + nip05: None, + nip42: None, + nip43: None, + nip45: None, + nip50: None, + nip56: None, + nip66: None, + wot: None, + } +} + +/// Settings with the EWMA limiter selected explicitly, which is the shipped default. +pub fn settings_with_ewma() -> Settings { + let mut settings = settings(); + + settings.limits = Some(nostream_config::settings::Limits { + rate_limiter: Some(RateLimiterSettings { + strategy: nostream_config::settings::RateLimiterStrategy::Ewma, + }), + ..Default::default() + }); + + settings +} + +/// NIP-42 on, with a zero session TTL (the shipped default) and restricted reads enabled +/// with the default kinds. +pub fn nip42_settings() -> Nip42Settings { + Nip42Settings { + auth_required: None, + session_expiry_seconds: Some(0), + restricted_reads: Some(nostream_config::settings::Nip42RestrictedReads { + enabled: true, + kinds: None, + }), + } +} + +/// NIP-43 on, invite requests allowed, no whitelist. +pub fn nip43_settings() -> Nip43Settings { + Nip43Settings { + enabled: true, + invite_code_expiry_seconds: None, + default_max_uses: None, + allow_invite_requests: Some(true), + invite_request_whitelist: None, + } +} + +/// NIP-45 on. +pub fn nip45_settings() -> Nip45Settings { + Nip45Settings { + enabled: Some(true), + } +} + +/// NIP-50 off, which is the shipped default. +pub fn nip50_settings() -> Nip50Settings { + Nip50Settings::default() +} + +/// NIP-56 on, hiding actionable reports. +pub fn nip56_settings() -> Nip56Settings { + Nip56Settings { + enabled: true, + trusted_moderators: Vec::new(), + hide_actionable_reports: true, + } +} + +/// An event repository holding its rows in memory. +#[derive(Debug, Default)] +pub struct InMemoryEventRepository { + records: Mutex>, + fail_count: AtomicBool, + fail_queries: AtomicBool, + /// When set, `find_by_filters` holds its stream until a permit is released, which is how + /// a test gets a read in flight long enough to `CLOSE` it. + read_gate: Mutex>>, +} + +impl InMemoryEventRepository { + /// An empty store. + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + /// Seeds `records` with the given events, as stored rows. + pub fn with_events(events: impl IntoIterator) -> Arc { + let store = Self::default(); + let mut records = store + .records + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + for event in events { + records.push(EventRecord { + row_id: None, + event, + first_seen: None, + deleted_at: None, + expires_at: None, + event_deduplication: None, + remote_address: None, + }); + } + + drop(records); + + Arc::new(store) + } + + /// Makes every `create` fail, to exercise the handler's error path. + pub fn fail_create(&self, fail: bool) { + self.fail_count.store(fail, Ordering::SeqCst); + } + + /// Makes every `count_by_filters` fail. + pub fn fail_count(&self) { + self.fail_queries.store(true, Ordering::SeqCst); + } + + /// Makes every read wait for a permit. Grant one with `Semaphore::add_permits` to let a + /// read through. + pub fn gate_reads(&self) -> Arc { + let gate = Arc::new(tokio::sync::Semaphore::new(0)); + + *self + .read_gate + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(Arc::clone(&gate)); + + gate + } + + /// The stored events, in insertion order. + pub fn events(&self) -> Vec { + self.records + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .iter() + .map(|record| record.event.clone()) + .collect() + } +} + +impl EventRepository for InMemoryEventRepository { + fn create<'a>(&'a self, record: &'a EventRecord) -> StorageFuture<'a, u64> { + Box::pin(async move { + if self.fail_count.load(Ordering::SeqCst) { + return Err(StorageError::InvalidArgument { + message: "in-memory double: create failed", + }); + } + + let mut records = self + .records + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + if records + .iter() + .any(|stored| stored.event.id == record.event.id) + { + return Ok(0); + } + + records.push(record.clone()); + + Ok(1) + }) + } + + fn create_many<'a>(&'a self, records: &'a [EventRecord]) -> StorageFuture<'a, u64> { + Box::pin(async move { Ok(records.len() as u64) }) + } + + fn upsert<'a>(&'a self, record: &'a EventRecord) -> StorageFuture<'a, u64> { + Box::pin(async move { + let mut records = self + .records + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + records.push(record.clone()); + + Ok(1) + }) + } + + fn upsert_many<'a>(&'a self, records: &'a [EventRecord]) -> StorageFuture<'a, u64> { + Box::pin(async move { Ok(records.len() as u64) }) + } + + fn find_by_filters<'a>( + &'a self, + filters: &'a [Filter], + ) -> StorageFuture<'a, QueryStream<'a, EventRecord>> { + let gate = self + .read_gate + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + + Box::pin(async move { + let records = self + .records + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + + let matched: Vec> = records + .into_iter() + .filter(|record| filters.iter().any(|filter| filter.matches(&record.event))) + .map(Ok) + .collect(); + + let Some(gate) = gate else { + return Ok(Box::pin(stream::iter(matched)) as QueryStream<'a, EventRecord>); + }; + + let permit = gate + .acquire_owned() + .await + .map_err(|_| StorageError::InvalidArgument { + message: "in-memory double: the read gate was closed", + })?; + + // The permit is held by the stream, so the read stays gated until it completes + // or the handler drops it. + let gated = stream::iter(matched).map(move |item| { + let _held = &permit; + + item + }); + + Ok(Box::pin(gated) as QueryStream<'a, EventRecord>) + }) + } + + fn count_by_filters<'a>(&'a self, filters: &'a [Filter]) -> StorageFuture<'a, u64> { + Box::pin(async move { + if self.fail_queries.load(Ordering::SeqCst) { + return Err(StorageError::InvalidArgument { + message: "in-memory double: count failed", + }); + } + + let records = self + .records + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + Ok(records + .iter() + .filter(|record| filters.iter().any(|filter| filter.matches(&record.event))) + .count() as u64) + }) + } + + fn find_reconciliation_items<'a>( + &'a self, + _filter: &'a Filter, + ) -> StorageFuture<'a, QueryStream<'a, ReconciliationItem>> { + Box::pin( + async move { Ok(Box::pin(stream::empty()) as QueryStream<'a, ReconciliationItem>) }, + ) + } + + fn delete_by_pubkey_and_ids<'a>( + &'a self, + _pubkey: &'a Pubkey, + _ids: &'a [EventId], + ) -> StorageFuture<'a, u64> { + Box::pin(async move { + Err(StorageError::InvalidArgument { + message: "in-memory double: not used by these tests", + }) + }) + } + + fn delete_by_pubkey_except_kinds<'a>( + &'a self, + _pubkey: &'a Pubkey, + _excluded_kinds: &'a [Kind], + ) -> StorageFuture<'a, u64> { + Box::pin(async move { + Err(StorageError::InvalidArgument { + message: "in-memory double: not used by these tests", + }) + }) + } + + fn has_active_request_to_vanish<'a>(&'a self, _pubkey: &'a Pubkey) -> StorageFuture<'a, bool> { + Box::pin(async move { Ok(false) }) + } + + fn delete_expired_and_retained<'a>( + &'a self, + _options: &'a EventRetentionOptions, + ) -> StorageFuture<'a, EventPurgeCounts> { + Box::pin(async move { Ok(EventPurgeCounts::default()) }) + } +} + +/// A user repository holding its rows in memory. +#[derive(Debug, Default)] +pub struct InMemoryUserRepository { + users: Mutex>, +} + +impl InMemoryUserRepository { + /// An empty store. + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + /// Adds or replaces one user row. + pub fn insert(&self, user: User) { + self.users + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(user.pubkey, user); + } +} + +impl UserRepository for InMemoryUserRepository { + fn find_by_pubkey<'a>(&'a self, pubkey: &'a Pubkey) -> StorageFuture<'a, Option> { + Box::pin(async move { + Ok(self + .users + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(pubkey) + .cloned()) + }) + } + + fn upsert<'a>(&'a self, patch: &'a UserPatch) -> StorageFuture<'a, u64> { + Box::pin(async move { + let now = chrono::Utc::now(); + self.users + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert( + patch.pubkey, + User { + pubkey: patch.pubkey, + is_admitted: patch.is_admitted, + is_vanished: patch.is_vanished, + balance: 0, + created_at: now, + updated_at: now, + }, + ); + + Ok(1) + }) + } + + fn get_balance_by_pubkey<'a>(&'a self, pubkey: &'a Pubkey) -> StorageFuture<'a, i64> { + Box::pin(async move { + Ok(self + .users + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(pubkey) + .map_or(0, |user| user.balance)) + }) + } + + fn is_vanished<'a>(&'a self, pubkey: &'a Pubkey) -> StorageFuture<'a, bool> { + Box::pin(async move { + Ok(self + .users + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(pubkey) + .is_some_and(|user| user.is_vanished)) + }) + } + + fn set_vanished<'a>(&'a self, pubkey: &'a Pubkey, vanished: bool) -> StorageFuture<'a, u64> { + Box::pin(async move { + let mut users = self + .users + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + match users.get_mut(pubkey) { + Some(user) => { + user.is_vanished = vanished; + Ok(1) + } + None => Ok(0), + } + }) + } + + fn admit_user<'a>( + &'a self, + pubkey: &'a Pubkey, + _admitted_at: chrono::DateTime, + ) -> StorageFuture<'a, ()> { + Box::pin(async move { + if let Some(user) = self + .users + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get_mut(pubkey) + { + user.is_admitted = true; + } + + Ok(()) + }) + } + + fn revoke_admission<'a>(&'a self, pubkey: &'a Pubkey) -> StorageFuture<'a, u64> { + Box::pin(async move { + if let Some(user) = self + .users + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get_mut(pubkey) + { + user.is_admitted = false; + return Ok(1); + } + + Ok(0) + }) + } +} + +/// A NIP-05 verification repository holding its rows in memory. +#[derive(Debug, Default)] +pub struct InMemoryNip05Repository { + rows: Mutex>, +} + +impl InMemoryNip05Repository { + /// An empty store. + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + /// Adds or replaces one verification row. + pub fn insert(&self, verification: Nip05Verification) { + self.rows + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(verification.pubkey, verification); + } +} + +impl Nip05VerificationRepository for InMemoryNip05Repository { + fn find_by_pubkey<'a>( + &'a self, + pubkey: &'a Pubkey, + ) -> StorageFuture<'a, Option> { + Box::pin(async move { + Ok(self + .rows + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(pubkey) + .cloned()) + }) + } + + fn upsert<'a>(&'a self, verification: &'a Nip05Verification) -> StorageFuture<'a, u64> { + Box::pin(async move { + self.rows + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(verification.pubkey, verification.clone()); + + Ok(1) + }) + } + + fn find_pending_verifications<'a>( + &'a self, + _update_frequency_ms: i64, + _max_failures: i32, + _limit: u64, + ) -> StorageFuture<'a, Vec> { + Box::pin(async move { Ok(Vec::new()) }) + } + + fn delete_by_pubkey<'a>(&'a self, pubkey: &'a Pubkey) -> StorageFuture<'a, u64> { + Box::pin(async move { + Ok(self + .rows + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(pubkey) + .map_or(0, |_| 1)) + }) + } +} + +/// An invite-code repository that records the codes it mints. +#[derive(Debug, Default)] +pub struct InMemoryInviteCodeRepository { + codes: Mutex>, +} + +impl InMemoryInviteCodeRepository { + /// An empty store. + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + /// The codes minted, in code order, with the options each was minted with. + pub fn minted(&self) -> Vec<(String, CreateInviteCodeOptions)> { + self.codes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .iter() + .map(|(code, options)| (code.clone(), options.clone())) + .collect() + } +} + +impl InviteCodeRepository for InMemoryInviteCodeRepository { + fn create<'a>( + &'a self, + code: &'a str, + options: &'a CreateInviteCodeOptions, + ) -> StorageFuture<'a, InviteCode> { + Box::pin(async move { + if self + .codes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(code.to_owned(), options.clone()) + .is_some() + { + return Err(StorageError::InvalidArgument { + message: "in-memory double: duplicate invite code", + }); + } + + let now = chrono::Utc::now(); + + Ok(InviteCode { + code: code.to_owned(), + created_by: options.created_by, + claimed_by: None, + expires_at: options.expires_at, + remaining_uses: match options.remaining_uses { + nostream_storage::repositories::FieldUpdate::Set(uses) => Some(uses), + _ => None, + }, + created_at: now, + updated_at: now, + }) + }) + } + + fn find_by_code<'a>(&'a self, code: &'a str) -> StorageFuture<'a, Option> { + Box::pin(async move { + Ok(self + .codes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(code) + .map(|options| InviteCode { + code: code.to_owned(), + created_by: options.created_by, + claimed_by: None, + expires_at: options.expires_at, + remaining_uses: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + })) + }) + } + + fn claim_code<'a>(&'a self, _code: &'a str, _pubkey: &'a Pubkey) -> StorageFuture<'a, bool> { + Box::pin(async move { Ok(true) }) + } + + fn find_active_codes<'a>(&'a self, _limit: u64) -> StorageFuture<'a, Vec> { + Box::pin(async move { Ok(Vec::new()) }) + } + + fn delete_expired_codes<'a>(&'a self) -> StorageFuture<'a, u64> { + Box::pin(async move { Ok(0) }) + } +} + +/// A DVM job repository that records nothing the relay reads. +#[derive(Debug, Default)] +pub struct InMemoryDvmJobRepository; + +impl InMemoryDvmJobRepository { + /// An empty store. + pub fn new() -> Arc { + Arc::new(Self) + } +} + +impl nostream_storage::repositories::DvmJobRepository for InMemoryDvmJobRepository { + fn create<'a>( + &'a self, + id: &'a EventId, + requester: &'a Pubkey, + kind: Kind, + ) -> StorageFuture<'a, DvmJob> { + Box::pin(async move { + let now = chrono::Utc::now(); + + Ok(DvmJob { + id: *id, + requester_pubkey: *requester, + kind, + worker_index: None, + status: DvmJobStatus::Submitted, + result_event_id: None, + error: None, + picked_up_at: None, + completed_at: None, + created_at: now, + updated_at: now, + }) + }) + } + + fn find_by_id<'a>(&'a self, _id: &'a EventId) -> StorageFuture<'a, Option> { + Box::pin(async move { Ok(None) }) + } + + fn assign_worker<'a>( + &'a self, + _id: &'a EventId, + _worker_index: i32, + ) -> StorageFuture<'a, bool> { + Box::pin(async move { Ok(false) }) + } + + fn update_status<'a>( + &'a self, + _update: &'a nostream_storage::repositories::DvmJobUpdate, + ) -> StorageFuture<'a, Option> { + Box::pin(async move { Ok(None) }) + } + + fn find_pending_jobs<'a>( + &'a self, + _limit: u64, + _kinds: Option<&'a [Kind]>, + ) -> StorageFuture<'a, Vec> { + Box::pin(async move { Ok(Vec::new()) }) + } +} + +/// A report repository with one settable actionable-target snapshot. +#[derive(Debug, Default)] +pub struct InMemoryReportRepository { + targets: Mutex>, + queries: AtomicU64, + fail_queries: AtomicBool, +} + +impl InMemoryReportRepository { + /// An empty store. + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + /// Replaces the actionable-target snapshot. + pub fn set_targets(&self, targets: Vec) { + *self + .targets + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = targets; + } + + /// How many `find_actionable_targets` calls have run. + pub fn queries(&self) -> u64 { + self.queries.load(Ordering::SeqCst) + } + + /// Makes every `find_actionable_targets` call fail, as an unavailable database would. + pub fn fail_queries(&self) { + self.fail_queries.store(true, Ordering::SeqCst); + } +} + +impl ReportRepository for InMemoryReportRepository { + fn create<'a>( + &'a self, + _report: &'a nostream_storage::repositories::NewReport, + ) -> StorageFuture<'a, Report> { + Box::pin(async move { + Err(StorageError::InvalidArgument { + message: "in-memory double: not used by these tests", + }) + }) + } + + fn create_many<'a>( + &'a self, + _reports: &'a [nostream_storage::repositories::NewReport], + ) -> StorageFuture<'a, Vec> { + Box::pin(async move { Ok(Vec::new()) }) + } + + fn find_by_event_id<'a>(&'a self, _event_id: &'a EventId) -> StorageFuture<'a, Vec> { + Box::pin(async move { Ok(Vec::new()) }) + } + + fn find_actionable<'a>(&'a self, _limit: u64) -> StorageFuture<'a, Vec> { + Box::pin(async move { Ok(Vec::new()) }) + } + + fn find_actionable_targets<'a>(&'a self) -> StorageFuture<'a, Vec> { + self.queries.fetch_add(1, Ordering::SeqCst); + + if self.fail_queries.load(Ordering::SeqCst) { + return Box::pin(async move { + Err(StorageError::InvalidArgument { + message: "in-memory double: the report query failed", + }) + }); + } + + Box::pin(async move { + Ok(self + .targets + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone()) + }) + } +} + +/// A limiter whose store always fails, for the fail-closed paths. +#[derive(Debug, Default, Clone, Copy)] +pub struct FailingLimiter; + +impl crate::limits::Limiter for FailingLimiter { + fn hit( + &self, + _key: &str, + _step: u64, + _config: &crate::limits::RateLimitConfig, + ) -> Result { + Err(RelayError::RateLimiter( + "in-memory double: the store failed".to_owned(), + )) + } + + fn strategy(&self) -> nostream_config::settings::RateLimiterStrategy { + nostream_config::settings::RateLimiterStrategy::Ewma + } +} + +/// The six doubles as one [`Stores`] value. +pub fn stores( + events: Arc, + users: Arc, + nip05: Arc, + invites: Arc, + reports: Arc, +) -> Arc { + Arc::new(Stores { + events, + users, + nip05, + invites, + dvm_jobs: InMemoryDvmJobRepository::new(), + reports, + }) +} + +/// The parts of a [`HandlerContext`] a test builds before adding its own socket. +pub struct Fixture { + pub settings: Arc, + pub stores: Arc, + pub events: Arc, + pub users: Arc, + pub nip05: Arc, + pub invites: Arc, + pub reports: Arc, + pub hidden: Arc, + pub hub: tokio::sync::broadcast::Sender, +} + +impl Fixture { + /// A fixture over fresh, empty doubles. + pub fn new(settings: Settings) -> Self { + let events = InMemoryEventRepository::new(); + let users = InMemoryUserRepository::new(); + let nip05 = InMemoryNip05Repository::new(); + let invites = InMemoryInviteCodeRepository::new(); + let reports = InMemoryReportRepository::new(); + let (hub, _receiver) = tokio::sync::broadcast::channel(64); + + Self { + settings: Arc::new(settings), + stores: stores( + Arc::clone(&events), + Arc::clone(&users), + Arc::clone(&nip05), + Arc::clone(&invites), + Arc::clone(&reports), + ), + events, + users, + nip05, + invites, + reports, + hidden: HiddenContentCache::new(), + hub, + } + } + + /// Stores one event as a live row, for a test that reads it back. + pub async fn seed_event(&self, event: Event) { + let record = EventRecord { + row_id: None, + event, + first_seen: None, + deleted_at: None, + expires_at: None, + event_deduplication: None, + remote_address: None, + }; + + let _ = EventRepository::create(self.events.as_ref(), &record).await; + } + + /// A handler context for one socket, with the given clock and key source. + pub fn context( + &self, + clock: Arc, + keys: RelayKeySource, + connection: Arc, + sink: crate::sink::FrameSink, + ) -> HandlerContext { + let limits = Arc::new(RateLimits::new(&self.settings, Arc::clone(&clock))); + + self.context_with_limits(clock, keys, connection, sink, limits) + } + + /// A handler context over explicit rate limits, which is how a failing store is wired. + pub fn context_with_limits( + &self, + clock: Arc, + keys: RelayKeySource, + connection: Arc, + sink: crate::sink::FrameSink, + limits: Arc, + ) -> HandlerContext { + HandlerContext { + settings: Arc::clone(&self.settings), + clock: Arc::clone(&clock), + sink, + connection, + stores: Arc::clone(&self.stores), + limits, + hidden: Arc::clone(&self.hidden), + keys: Arc::new(keys), + broadcast: self.hub.clone(), + pow: Arc::new(Mutex::new( + nostream_core::adaptive_pow::AdaptivePowState::default(), + )), + } + } + + /// A context whose sink drops everything and whose socket has authenticated as `pubkey`. + pub fn anonymous_context(&self, clock: Arc, keys: RelayKeySource) -> HandlerContext { + let connection = Arc::new(Connection::new( + "127.0.0.1".to_owned(), + nostream_core::nip42::Nip42Session::with_challenge("challenge", 0), + )); + + self.context(clock, keys, connection, crate::sink::FrameSink::closed()) + } + + /// A context connected as `pubkey`, with a sink that drops everything. + pub fn authenticated_context( + &self, + clock: Arc, + keys: RelayKeySource, + pubkey: Pubkey, + ) -> HandlerContext { + let context = self.anonymous_context(clock, keys); + + context.connection.authenticate( + pubkey, + EventId::from_byte_array([9; 32]), + context.now_secs(), + ); + + context + } + + /// Registers one subscription on the context's socket. + pub fn subscribe(&self, context: &HandlerContext, id: &str, filters: Vec) { + context.connection.subscribe(Subscription { + id: id.to_owned(), + filters, + }); + } +} + +/// A broadcast receiver over the fixture's hub. +pub fn hub_receiver( + hub: &tokio::sync::broadcast::Sender, +) -> tokio::sync::broadcast::Receiver { + hub.subscribe() +} diff --git a/crates/nostream-relay/src/transport.rs b/crates/nostream-relay/src/transport.rs new file mode 100644 index 000000000..774e6f920 --- /dev/null +++ b/crates/nostream-relay/src/transport.rs @@ -0,0 +1,173 @@ +//! The websocket transport: the handshake, the size limit, the close codes and the drain +//! timeout. Mirrors `src/factories/worker-factory.ts:73-91` and +//! `src/adapters/web-socket-server-adapter.ts`. +//! +//! The upgrade is accepted for every path — this job adds no HTTP route, so a non-upgrade +//! request gets tungstenite's refusal and nothing else. +//! +//! **Compression is declined** (ADR 0004): `tokio-tungstenite` offers no +//! `permessage-deflate`, so a client's offer is ignored and the session runs uncompressed. +//! The 101 response therefore carries no `Sec-WebSocket-Extensions` header. + +use std::time::Duration; + +use nostream_config::settings::Settings; +use tokio::net::TcpStream; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::tungstenite::protocol::CloseFrame; +use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; +use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; + +use crate::connection::AcceptedTransport; +use crate::error::RelayError; + +/// The fallback message limit (`maxPayload: maxPayloadSize ?? 131072`, +/// `src/factories/worker-factory.ts:78`). +pub const DEFAULT_MAX_PAYLOAD: usize = 131_072; + +/// The drain timeout when `WS_DRAIN_TIMEOUT_MS` is unusable +/// (`DEFAULT_WS_DRAIN_TIMEOUT_MS`, `src/utils/shutdown-state.ts:1`). +pub const DEFAULT_WS_DRAIN_TIMEOUT_MS: u64 = 30_000; + +/// The close reason a draining server sends (`drainAndClose`). +pub const DRAIN_REASON: &str = "relay shutting down"; + +/// `network.maxPayloadSize` (`network.max_payload_size` in the shipped YAML), else 131072. +pub fn max_payload_size(settings: &Settings) -> usize { + settings + .network + .max_payload_size + .and_then(|size| usize::try_from(size).ok()) + .filter(|size| *size > 0) + .unwrap_or(DEFAULT_MAX_PAYLOAD) +} + +/// The websocket configuration the limit implies. +/// +/// `max_message_size` bounds the reassembled message and `max_frame_size` a single frame, so +/// a fragmented message cannot grow past the limit either. The write buffer is unbuffered so +/// every frame leaves when it is sent, which is what the capture's `client.send` does. +pub fn web_socket_config(max_payload: usize) -> WebSocketConfig { + WebSocketConfig::default() + .write_buffer_size(0) + .max_message_size(Some(max_payload)) + .max_frame_size(Some(max_payload)) +} + +/// Completes the websocket handshake over an accepted socket. +pub async fn upgrade( + stream: TcpStream, + max_payload: usize, +) -> Result { + tokio_tungstenite::accept_async_with_config(stream, Some(web_socket_config(max_payload))) + .await + .map_err(|error| RelayError::Handshake(error.to_string())) +} + +/// A close frame with a code and a reason, as `close(code, reason)` sends. +pub fn close_frame(code: CloseCode, reason: &'static str) -> CloseFrame { + CloseFrame { + code, + reason: reason.into(), + } +} + +/// The frame an oversized message is closed with: 1009, empty reason (measured). +pub fn too_big_frame() -> CloseFrame { + CloseFrame { + code: CloseCode::Size, + reason: "".into(), + } +} + +/// The frame a protocol violation is closed with: 1002. +pub fn protocol_error_frame() -> CloseFrame { + CloseFrame { + code: CloseCode::Protocol, + reason: "".into(), + } +} + +/// `getWsDrainTimeoutMs` (`src/utils/shutdown-state.ts:17-29`): unset, empty, non-numeric or +/// negative resolves to 30000; otherwise the parsed value in milliseconds. +pub fn resolve_drain_timeout(raw: Option<&str>) -> u64 { + let Some(raw) = raw.filter(|value| !value.is_empty()) else { + return DEFAULT_WS_DRAIN_TIMEOUT_MS; + }; + + match raw.parse::() { + Ok(millis) if millis >= 0 => u64::try_from(millis).unwrap_or(DEFAULT_WS_DRAIN_TIMEOUT_MS), + _ => DEFAULT_WS_DRAIN_TIMEOUT_MS, + } +} + +/// `resolve_drain_timeout` over `WS_DRAIN_TIMEOUT_MS`. +pub fn drain_timeout_from_env() -> u64 { + resolve_drain_timeout(std::env::var("WS_DRAIN_TIMEOUT_MS").ok().as_deref()) +} + +/// The drain timeout as a duration. +pub fn drain_timeout() -> Duration { + Duration::from_millis(drain_timeout_from_env()) +} + +/// Whether a websocket error is the max-payload failure (measured: close code 1009). +pub fn is_message_too_long(error: &tokio_tungstenite::tungstenite::Error) -> bool { + matches!( + error, + tokio_tungstenite::tungstenite::Error::Capacity( + tokio_tungstenite::tungstenite::error::CapacityError::MessageTooLong { .. } + ) + ) +} + +/// Whether a websocket error is the RSV-bit violation a compression-declining server sees +/// (`Invalid WebSocket frame: RSV1 must be clear`, `web-socket-adapter.ts:73`). +pub fn is_protocol_error(error: &tokio_tungstenite::tungstenite::Error) -> bool { + matches!(error, tokio_tungstenite::tungstenite::Error::Protocol(_)) +} + +/// A negotiated socket, for callers that hold the concrete type. +pub type Transport = WebSocketStream; + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use super::*; + + fn settings_with(size: Option) -> Settings { + let mut settings = crate::testing::settings(); + settings.network.max_payload_size = size; + + settings + } + + #[test] + fn max_payload_falls_back_to_131072() { + assert_eq!(max_payload_size(&settings_with(None)), 131_072); + assert_eq!(max_payload_size(&settings_with(Some(0))), 131_072); + assert_eq!(max_payload_size(&settings_with(Some(524_288))), 524_288); + } + + #[test] + fn a_null_extension_header_is_never_offered() { + // The configuration has no extension slot at all; this pins the shape the handshake + // is built from, so a future compression feature has to change this test. + let config = web_socket_config(1024); + + assert_eq!(config.max_message_size, Some(1024)); + assert_eq!(config.max_frame_size, Some(1024)); + assert_eq!(config.write_buffer_size, 0); + } + + #[test] + fn drain_timeout_resolution_matches_the_capture() { + assert_eq!(resolve_drain_timeout(None), 30_000); + assert_eq!(resolve_drain_timeout(Some("")), 30_000); + assert_eq!(resolve_drain_timeout(Some("abc")), 30_000); + assert_eq!(resolve_drain_timeout(Some("-1")), 30_000); + assert_eq!(resolve_drain_timeout(Some("10")), 10); + assert_eq!(resolve_drain_timeout(Some("0")), 0); + } +} diff --git a/crates/nostream-relay/src/unavailable.rs b/crates/nostream-relay/src/unavailable.rs new file mode 100644 index 000000000..2bbaa1013 --- /dev/null +++ b/crates/nostream-relay/src/unavailable.rs @@ -0,0 +1,274 @@ +//! Repository seams for a daemon whose storage backend is not wired yet. +//! +//! The relay is generic over `nostream-storage`'s repository traits, and the daemon has no +//! live backend to hand it: the LMDB environment is a labelled stub +//! (`crates/nostream-storage/src/lib.rs:17-19`) and the Postgres backend needs a database the +//! daemon is not yet configured with. Wiring these seams keeps the wire served — handshake, +//! NIP-42 challenge, REQ/COUNT validation — while every storage call reports +//! `StorageError::NotImplemented`, which is the workspace's convention for an unwired +//! backend (`CLAUDE.md:148-151`). + +use nostream_core::{EventId, Filter, Kind, Pubkey}; +use nostream_storage::StorageError; +use nostream_storage::repositories::{ + ActionableTarget, CreateInviteCodeOptions, DvmJob, DvmJobUpdate, EventPurgeCounts, EventRecord, + EventRepository, EventRetentionOptions, InviteCode, InviteCodeRepository, Nip05Verification, + Nip05VerificationRepository, QueryStream, ReconciliationItem, Report, ReportRepository, + StorageFuture, User, UserPatch, UserRepository, +}; + +use crate::handlers::Stores; + +/// The six seams, every call reporting `NotImplemented`. +pub fn stores() -> Stores { + Stores { + events: std::sync::Arc::new(UnavailableEventRepository), + users: std::sync::Arc::new(UnavailableUserRepository), + nip05: std::sync::Arc::new(UnavailableNip05Repository), + invites: std::sync::Arc::new(UnavailableInviteCodeRepository), + dvm_jobs: std::sync::Arc::new(UnavailableDvmJobRepository), + reports: std::sync::Arc::new(UnavailableReportRepository), + } +} + +fn unavailable() -> StorageError { + StorageError::NotImplemented { + module: "storage::backend::wiring", + } +} + +/// The event store. +#[derive(Debug, Default, Clone, Copy)] +pub struct UnavailableEventRepository; + +impl EventRepository for UnavailableEventRepository { + fn create<'a>(&'a self, _record: &'a EventRecord) -> StorageFuture<'a, u64> { + Box::pin(async { Err(unavailable()) }) + } + + fn create_many<'a>(&'a self, _records: &'a [EventRecord]) -> StorageFuture<'a, u64> { + Box::pin(async { Err(unavailable()) }) + } + + fn upsert<'a>(&'a self, _record: &'a EventRecord) -> StorageFuture<'a, u64> { + Box::pin(async { Err(unavailable()) }) + } + + fn upsert_many<'a>(&'a self, _records: &'a [EventRecord]) -> StorageFuture<'a, u64> { + Box::pin(async { Err(unavailable()) }) + } + + fn find_by_filters<'a>( + &'a self, + _filters: &'a [Filter], + ) -> StorageFuture<'a, QueryStream<'a, EventRecord>> { + Box::pin(async { Err(unavailable()) }) + } + + fn count_by_filters<'a>(&'a self, _filters: &'a [Filter]) -> StorageFuture<'a, u64> { + Box::pin(async { Err(unavailable()) }) + } + + fn find_reconciliation_items<'a>( + &'a self, + _filter: &'a Filter, + ) -> StorageFuture<'a, QueryStream<'a, ReconciliationItem>> { + Box::pin(async { Err(unavailable()) }) + } + + fn delete_by_pubkey_and_ids<'a>( + &'a self, + _pubkey: &'a Pubkey, + _ids: &'a [EventId], + ) -> StorageFuture<'a, u64> { + Box::pin(async { Err(unavailable()) }) + } + + fn delete_by_pubkey_except_kinds<'a>( + &'a self, + _pubkey: &'a Pubkey, + _excluded_kinds: &'a [Kind], + ) -> StorageFuture<'a, u64> { + Box::pin(async { Err(unavailable()) }) + } + + fn has_active_request_to_vanish<'a>(&'a self, _pubkey: &'a Pubkey) -> StorageFuture<'a, bool> { + Box::pin(async { Err(unavailable()) }) + } + + fn delete_expired_and_retained<'a>( + &'a self, + _options: &'a EventRetentionOptions, + ) -> StorageFuture<'a, EventPurgeCounts> { + Box::pin(async { Err(unavailable()) }) + } +} + +/// The user store. +#[derive(Debug, Default, Clone, Copy)] +pub struct UnavailableUserRepository; + +impl UserRepository for UnavailableUserRepository { + fn find_by_pubkey<'a>(&'a self, _pubkey: &'a Pubkey) -> StorageFuture<'a, Option> { + Box::pin(async { Err(unavailable()) }) + } + + fn upsert<'a>(&'a self, _user: &'a UserPatch) -> StorageFuture<'a, u64> { + Box::pin(async { Err(unavailable()) }) + } + + fn get_balance_by_pubkey<'a>(&'a self, _pubkey: &'a Pubkey) -> StorageFuture<'a, i64> { + Box::pin(async { Err(unavailable()) }) + } + + fn is_vanished<'a>(&'a self, _pubkey: &'a Pubkey) -> StorageFuture<'a, bool> { + Box::pin(async { Err(unavailable()) }) + } + + fn set_vanished<'a>(&'a self, _pubkey: &'a Pubkey, _vanished: bool) -> StorageFuture<'a, u64> { + Box::pin(async { Err(unavailable()) }) + } + + fn admit_user<'a>( + &'a self, + _pubkey: &'a Pubkey, + _admitted_at: chrono::DateTime, + ) -> StorageFuture<'a, ()> { + Box::pin(async { Err(unavailable()) }) + } + + fn revoke_admission<'a>(&'a self, _pubkey: &'a Pubkey) -> StorageFuture<'a, u64> { + Box::pin(async { Err(unavailable()) }) + } +} + +/// The NIP-05 store. +#[derive(Debug, Default, Clone, Copy)] +pub struct UnavailableNip05Repository; + +impl Nip05VerificationRepository for UnavailableNip05Repository { + fn find_by_pubkey<'a>( + &'a self, + _pubkey: &'a Pubkey, + ) -> StorageFuture<'a, Option> { + Box::pin(async { Err(unavailable()) }) + } + + fn upsert<'a>(&'a self, _verification: &'a Nip05Verification) -> StorageFuture<'a, u64> { + Box::pin(async { Err(unavailable()) }) + } + + fn find_pending_verifications<'a>( + &'a self, + _update_frequency_ms: i64, + _max_failures: i32, + _limit: u64, + ) -> StorageFuture<'a, Vec> { + Box::pin(async { Err(unavailable()) }) + } + + fn delete_by_pubkey<'a>(&'a self, _pubkey: &'a Pubkey) -> StorageFuture<'a, u64> { + Box::pin(async { Err(unavailable()) }) + } +} + +/// The invite-code store. +#[derive(Debug, Default, Clone, Copy)] +pub struct UnavailableInviteCodeRepository; + +impl InviteCodeRepository for UnavailableInviteCodeRepository { + fn create<'a>( + &'a self, + _code: &'a str, + _options: &'a CreateInviteCodeOptions, + ) -> StorageFuture<'a, InviteCode> { + Box::pin(async { Err(unavailable()) }) + } + + fn find_by_code<'a>(&'a self, _code: &'a str) -> StorageFuture<'a, Option> { + Box::pin(async { Err(unavailable()) }) + } + + fn claim_code<'a>(&'a self, _code: &'a str, _pubkey: &'a Pubkey) -> StorageFuture<'a, bool> { + Box::pin(async { Err(unavailable()) }) + } + + fn find_active_codes<'a>(&'a self, _limit: u64) -> StorageFuture<'a, Vec> { + Box::pin(async { Err(unavailable()) }) + } + + fn delete_expired_codes<'a>(&'a self) -> StorageFuture<'a, u64> { + Box::pin(async { Err(unavailable()) }) + } +} + +/// The DVM job store. +#[derive(Debug, Default, Clone, Copy)] +pub struct UnavailableDvmJobRepository; + +impl nostream_storage::repositories::DvmJobRepository for UnavailableDvmJobRepository { + fn create<'a>( + &'a self, + _id: &'a EventId, + _requester: &'a Pubkey, + _kind: Kind, + ) -> StorageFuture<'a, DvmJob> { + Box::pin(async { Err(unavailable()) }) + } + + fn find_by_id<'a>(&'a self, _id: &'a EventId) -> StorageFuture<'a, Option> { + Box::pin(async { Err(unavailable()) }) + } + + fn assign_worker<'a>( + &'a self, + _id: &'a EventId, + _worker_index: i32, + ) -> StorageFuture<'a, bool> { + Box::pin(async { Err(unavailable()) }) + } + + fn update_status<'a>(&'a self, _update: &'a DvmJobUpdate) -> StorageFuture<'a, Option> { + Box::pin(async { Err(unavailable()) }) + } + + fn find_pending_jobs<'a>( + &'a self, + _limit: u64, + _kinds: Option<&'a [Kind]>, + ) -> StorageFuture<'a, Vec> { + Box::pin(async { Err(unavailable()) }) + } +} + +/// The report store. +#[derive(Debug, Default, Clone, Copy)] +pub struct UnavailableReportRepository; + +impl ReportRepository for UnavailableReportRepository { + fn create<'a>( + &'a self, + _report: &'a nostream_storage::repositories::NewReport, + ) -> StorageFuture<'a, Report> { + Box::pin(async { Err(unavailable()) }) + } + + fn create_many<'a>( + &'a self, + _reports: &'a [nostream_storage::repositories::NewReport], + ) -> StorageFuture<'a, Vec> { + Box::pin(async { Err(unavailable()) }) + } + + fn find_by_event_id<'a>(&'a self, _event_id: &'a EventId) -> StorageFuture<'a, Vec> { + Box::pin(async { Err(unavailable()) }) + } + + fn find_actionable<'a>(&'a self, _limit: u64) -> StorageFuture<'a, Vec> { + Box::pin(async { Err(unavailable()) }) + } + + fn find_actionable_targets<'a>(&'a self) -> StorageFuture<'a, Vec> { + Box::pin(async { Err(unavailable()) }) + } +} diff --git a/crates/nostream-relay/tests/common/mod.rs b/crates/nostream-relay/tests/common/mod.rs new file mode 100644 index 000000000..e54901f3e --- /dev/null +++ b/crates/nostream-relay/tests/common/mod.rs @@ -0,0 +1,261 @@ +//! The integration tests' harness: a relay on an ephemeral port and a client that reads +//! frames with a deadline. +//! +//! Every test in this crate drives the public API only — `Relay`, `testing`'s doubles, and a +//! real websocket over loopback — so no test depends on a database, a proxy or the wall +//! clock (`TestClock` moves time explicitly). + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, dead_code)] + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use nostream_core::clock::{Clock, TestClock}; +use nostream_core::lifecycle::ShutdownState; +use nostream_relay::relay_key::RelayKeySource; +use nostream_relay::testing::Fixture; +use nostream_relay::{Relay, RelayConfig}; +use tokio::net::TcpListener; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; + +/// The instant every test starts at: 2023-11-14T22:13:20Z. +pub const NOW_SECS: i64 = 1_700_000_000; +/// The same instant in milliseconds. +pub const NOW_MS: u64 = 1_700_000_000_000; + +/// A relay serving on loopback, with its clock and fixture. +pub struct Server { + pub address: SocketAddr, + pub clock: Arc, + pub fixture: Fixture, + pub relay: Arc, + shutdown: ShutdownState, +} + +impl Server { + /// Starts a relay over `fixture` on an ephemeral port. + pub async fn start(fixture: Fixture) -> Self { + Self::start_with( + fixture, + RelayKeySource::from_values(None, None), + fast_config(), + ) + .await + } + + /// Starts a relay with an explicit key source and intervals. + pub async fn start_with(fixture: Fixture, keys: RelayKeySource, config: RelayConfig) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let address = listener.local_addr().expect("local addr"); + let clock = Arc::new(TestClock::new(NOW_MS)); + + let relay = Relay::with_keys( + (*fixture.settings).clone(), + Arc::clone(&fixture.stores), + clock.clone(), + keys, + ) + .with_config(config); + + let shutdown = ShutdownState::new(); + let connection_shutdown = shutdown.clone(); + let served = Arc::clone(&relay); + + tokio::spawn(async move { + let _ = served.serve(listener, connection_shutdown).await; + }); + + Self { + address, + clock, + fixture, + relay, + shutdown, + } + } + + /// Connects a client and consumes its NIP-42 challenge frame, returning the challenge. + pub async fn connect(&self) -> (Client, String) { + let mut client = Client::connect(self.address).await; + let challenge = client + .expect_frame() + .await + .strip_prefix(r#"["AUTH",""#) + .and_then(|rest| rest.strip_suffix(r#""]"#)) + .expect("the first frame is the AUTH challenge") + .to_owned(); + + (client, challenge) + } + + /// Begins draining; the serve task stops accepting and closes every connection. + pub fn begin_draining(&self) { + self.shutdown.begin_draining(); + } + + /// The shared drain flag, for a test that drives `handle_socket` itself. + pub fn shutdown(&self) -> ShutdownState { + self.shutdown.clone() + } +} + +/// The intervals tests run with: a fast drain deadline and a liveness interval that never +/// fires unless a test asks for it. +pub fn fast_config() -> RelayConfig { + RelayConfig { + liveness_interval: Duration::from_secs(600), + hidden_refresh_interval: Duration::from_secs(600), + drain_timeout: Duration::from_millis(300), + } +} + +/// A liveness configuration with a short interval. +pub fn liveness_config(interval: Duration) -> RelayConfig { + RelayConfig { + liveness_interval: interval, + ..fast_config() + } +} + +/// A websocket client with a deadline on every read. +pub struct Client { + stream: WebSocketStream>, +} + +impl Client { + /// Connects to `address`. + pub async fn connect(address: SocketAddr) -> Self { + let (stream, _response) = tokio::time::timeout( + Duration::from_secs(5), + tokio_tungstenite::connect_async(format!("ws://{address}/")), + ) + .await + .expect("connect within five seconds") + .expect("handshake"); + + Self { stream } + } + + /// Sends one text frame. + pub async fn send(&mut self, text: &str) { + self.stream + .send(Message::Text(text.to_owned().into())) + .await + .expect("send"); + } + + /// The next message, or `None` when the socket closes within the deadline. + pub async fn next_message(&mut self) -> Option { + match tokio::time::timeout(Duration::from_secs(2), self.stream.next()).await { + Ok(None) => None, + Ok(Some(Ok(message))) => Some(message), + Ok(Some(Err(error))) => panic!("client read failed: {error}"), + Err(_) => None, + } + } + + /// The next message, mapping a transport failure to `None`. A terminated connection + /// surfaces as a reset, which is the behaviour a rate-limited upgrade has. + pub async fn next_message_lossy(&mut self) -> Option { + match tokio::time::timeout(Duration::from_secs(2), self.stream.next()).await { + Ok(Some(Ok(message))) => Some(message), + _ => None, + } + } + + /// The next text frame, panicking when the socket closes instead. + pub async fn expect_frame(&mut self) -> String { + match self.next_message().await { + Some(Message::Text(text)) => text.as_str().to_owned(), + other => panic!("expected a text frame, got {other:?}"), + } + } + + /// Every frame until `EOSE`, in order. + pub async fn read_until_eose(&mut self) -> Vec { + let mut frames = Vec::new(); + + loop { + let frame = self.expect_frame().await; + let is_eose = frame.starts_with(r#"["EOSE","#); + + frames.push(frame); + + if is_eose { + return frames; + } + } + } + + /// Whether the socket is still open: a ping gets a pong. + pub async fn is_open(&mut self) -> bool { + if self + .stream + .send(Message::Ping(vec![7u8].into())) + .await + .is_err() + { + return false; + } + + matches!(self.next_message().await, Some(Message::Pong(_))) + } + + /// Closes the socket. + pub async fn close(&mut self) { + let _ = self.stream.close(None).await; + } + + /// The underlying stream, for a test that needs the handshake response. + pub fn stream(&mut self) -> &mut WebSocketStream> { + &mut self.stream + } +} + +/// The relay's own signing key in these tests: `RELAY_PRIVATE_KEY = 01…01`. +pub const RELAY_SECRET: [u8; 32] = [1u8; 32]; + +/// A key source whose relay key is [`RELAY_SECRET`]. +pub fn relay_keys() -> RelayKeySource { + RelayKeySource::from_values(Some(hex::encode(RELAY_SECRET)), None) +} + +/// A client's key, distinct from the relay's. +pub const CLIENT_SECRET: [u8; 32] = [7u8; 32]; + +/// Builds and signs an event with `secret`. +pub fn signed_event( + secret: &[u8; 32], + kind: u64, + tags: Vec>, + content: &str, + created_at: i64, +) -> nostream_core::Event { + use nostream_core::Kind; + use nostream_core::event::{EventId, Pubkey, Signature}; + + let mut event = nostream_core::Event { + id: EventId::from_byte_array([0; 32]), + pubkey: Pubkey::from_byte_array([0; 32]), + created_at, + kind: Kind(kind), + tags: tags + .into_iter() + .map(|tag| tag.into_iter().map(str::to_owned).collect()) + .collect(), + content: content.to_owned(), + sig: Signature::from_byte_array([0; 64]), + }; + + nostream_core::crypto::sign_event(secret, &mut event).expect("sign the test event"); + + event +} + +/// The relay's clock as a trait object. +pub fn clock_of(server: &Server) -> Arc { + Arc::clone(&server.clock) as Arc +} diff --git a/crates/nostream-relay/tests/connection.rs b/crates/nostream-relay/tests/connection.rs new file mode 100644 index 000000000..a987e2704 --- /dev/null +++ b/crates/nostream-relay/tests/connection.rs @@ -0,0 +1,433 @@ +//! The per-socket state (R26): the subscription table, the NIP-42 session, the in-flight +//! read flags, and live dispatch over a real socket. +//! +//! Carries the state half of `test/unit/adapters/web-socket-adapter.spec.ts` (60) and +//! `message-handler-factory.spec.ts`'s dispatch ordering (6). + +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::float_cmp +)] + +mod common; + +use std::sync::Arc; + +use common::{CLIENT_SECRET, NOW_MS, NOW_SECS, Server, fast_config, relay_keys, signed_event}; +use nostream_core::clock::TestClock; +use nostream_core::filter::Filter; +use nostream_core::messages::SubscribeMessage; +use nostream_core::{EventId, Pubkey}; +use nostream_relay::connection::{Connection, Subscription}; +use nostream_relay::sink::FrameSink; +use nostream_relay::testing::{Fixture, settings}; + +/// A relay carrying the test's signing key, so an accepted event reaches the default path. +async fn start(fixture: Fixture) -> Server { + Server::start_with(fixture, relay_keys(), fast_config()).await +} + +fn connection() -> Arc { + Arc::new(Connection::new( + "127.0.0.1".to_owned(), + nostream_core::nip42::Nip42Session::with_challenge("challenge", 0), + )) +} + +// --------------------------------------------------------------------------------------- +// The subscription table +// --------------------------------------------------------------------------------------- + +#[test] +fn a_subscription_registers_once_and_an_identical_re_req_is_recognised() { + let connection = connection(); + let filters = vec![Filter { + kinds: Some(vec![nostream_core::Kind(1)]), + ..Default::default() + }]; + + assert!(connection.subscription("s1").is_none()); + + connection.subscribe(Subscription { + id: "s1".to_owned(), + filters: filters.clone(), + }); + + assert_eq!(connection.subscription_count(), 1); + assert!(connection.has_identical_subscription("s1", &filters)); + assert!(connection.subscription("s1").is_some()); + assert!(connection.subscription("other").is_none()); +} + +#[test] +fn unsubscribing_removes_the_entry_and_reports_it() { + let connection = connection(); + + connection.subscribe(Subscription { + id: "s1".to_owned(), + filters: Vec::new(), + }); + + let removed = connection.unsubscribe("s1").expect("the subscription"); + + assert_eq!(removed.id, "s1"); + assert_eq!(connection.subscription_count(), 0); + assert!(connection.unsubscribe("s1").is_none()); +} + +#[test] +fn a_later_subscription_replaces_the_filters_under_the_same_id() { + let connection = connection(); + let first = vec![Filter { + kinds: Some(vec![nostream_core::Kind(1)]), + ..Default::default() + }]; + let second = vec![Filter { + kinds: Some(vec![nostream_core::Kind(4)]), + ..Default::default() + }]; + + connection.subscribe(Subscription { + id: "s1".to_owned(), + filters: first, + }); + connection.subscribe(Subscription { + id: "s1".to_owned(), + filters: second.clone(), + }); + + assert_eq!(connection.subscription_count(), 1); + assert_eq!( + connection + .subscription("s1") + .map(|subscription| subscription.filters), + Some(second) + ); +} + +#[test] +fn matching_subscription_ids_are_returned_in_id_order() { + let connection = connection(); + let note = signed_event(&CLIENT_SECRET, 1, vec![], "", NOW_SECS); + + connection.subscribe(Subscription { + id: "note".to_owned(), + filters: vec![Filter { + kinds: Some(vec![nostream_core::Kind(1)]), + ..Default::default() + }], + }); + connection.subscribe(Subscription { + id: "channel".to_owned(), + filters: vec![Filter { + kinds: Some(vec![nostream_core::Kind(4)]), + ..Default::default() + }], + }); + connection.subscribe(Subscription { + id: "everything".to_owned(), + filters: vec![Filter::default()], + }); + + // The table is keyed by subscription id, so the ids come back sorted. + assert_eq!( + connection.matching_subscription_ids(¬e), + vec!["everything".to_owned(), "note".to_owned()] + ); +} + +// --------------------------------------------------------------------------------------- +// The NIP-42 session +// --------------------------------------------------------------------------------------- + +#[test] +fn a_connection_starts_unauthenticated_with_its_challenge() { + let connection = connection(); + + assert_eq!(connection.challenge(), "challenge"); + assert!(connection.authenticated_pubkeys(NOW_SECS).is_empty()); + assert!( + !connection + .authenticated_pubkeys(NOW_SECS) + .contains(&Pubkey::from_byte_array([7; 32])) + ); +} + +#[test] +fn authenticating_records_the_pubkey_in_order_and_refuses_a_replay() { + let connection = connection(); + let first = Pubkey::from_byte_array([1; 32]); + let second = Pubkey::from_byte_array([2; 32]); + + assert!(connection.authenticate(first, EventId::from_byte_array([1; 32]), NOW_SECS)); + assert!(connection.authenticate(second, EventId::from_byte_array([2; 32]), NOW_SECS)); + assert!( + !connection.authenticate(first, EventId::from_byte_array([1; 32]), NOW_SECS), + "an auth event id is single use" + ); + + assert_eq!( + connection.authenticated_pubkeys_in_order(NOW_SECS), + vec![first, second] + ); + assert!(connection.authenticated_pubkeys(NOW_SECS).contains(&first)); +} + +// --------------------------------------------------------------------------------------- +// In-flight reads and close (R26) +// --------------------------------------------------------------------------------------- + +#[test] +fn a_read_is_in_flight_until_it_ends_and_an_abort_notifies_it() { + let connection = connection(); + + assert!(!connection.read_in_flight("s1")); + + let cancelled = connection.begin_read("s1"); + + assert!(connection.read_in_flight("s1")); + + connection.abort_read("s1"); + + assert!(*cancelled.borrow()); + assert!(!connection.read_in_flight("s1")); + + let cancelled = connection.begin_read("s2"); + connection.end_read("s2"); + + assert!(!*cancelled.borrow()); + assert!(!connection.read_in_flight("s2")); +} + +#[test] +fn close_clears_the_table_the_session_and_the_reads() { + let connection = connection(); + let pubkey = Pubkey::from_byte_array([1; 32]); + + connection.subscribe(Subscription { + id: "s1".to_owned(), + filters: vec![Filter::default()], + }); + connection.authenticate(pubkey, EventId::from_byte_array([1; 32]), NOW_SECS); + let cancelled = connection.begin_read("s1"); + + connection.close(); + + assert_eq!(connection.subscription_count(), 0); + assert!(connection.authenticated_pubkeys(NOW_SECS).is_empty()); + assert!(!connection.read_in_flight("s1")); + assert!(*cancelled.borrow(), "an in-flight read is aborted"); +} + +// --------------------------------------------------------------------------------------- +// Live dispatch over a socket (R26) +// --------------------------------------------------------------------------------------- + +#[tokio::test] +async fn two_overlapping_subscriptions_both_receive_a_live_event() { + let server = start(Fixture::new(settings())).await; + let (mut client, _challenge) = server.connect().await; + + client.send(r#"["REQ","wide",{}]"#).await; + assert_eq!(client.expect_frame().await, r#"["EOSE","wide"]"#); + client.send(r#"["REQ","notes",{"kinds":[1]}]"#).await; + assert_eq!(client.expect_frame().await, r#"["EOSE","notes"]"#); + + let event = signed_event(&CLIENT_SECRET, 1, vec![], "live", NOW_SECS); + + client + .send(&format!( + r#"["EVENT",{}]"#, + serde_json::to_string(&event).expect("event json") + )) + .await; + + // The author's own OK, then the event on each matching subscription in id order. + assert_eq!( + client.expect_frame().await, + format!(r#"["OK","{}",true,""]"#, event.id) + ); + assert_eq!( + client.expect_frame().await, + format!( + r#"["EVENT","notes",{}]"#, + serde_json::to_string(&event).expect("event json") + ) + ); + assert_eq!( + client.expect_frame().await, + format!( + r#"["EVENT","wide",{}]"#, + serde_json::to_string(&event).expect("event json") + ) + ); +} + +#[tokio::test] +async fn a_non_matching_subscription_receives_nothing() { + let server = start(Fixture::new(settings())).await; + let (mut client, _challenge) = server.connect().await; + + client.send(r#"["REQ","channels",{"kinds":[4]}]"#).await; + assert_eq!(client.expect_frame().await, r#"["EOSE","channels"]"#); + + let event = signed_event(&CLIENT_SECRET, 1, vec![], "not a channel", NOW_SECS); + + client + .send(&format!( + r#"["EVENT",{}]"#, + serde_json::to_string(&event).expect("event json") + )) + .await; + + assert_eq!( + client.expect_frame().await, + format!(r#"["OK","{}",true,""]"#, event.id) + ); + + // A REQ proves the socket is still serving after the live dispatch produced no frame. + client.send(r#"["REQ","probe",{"kinds":[1]}]"#).await; + let frames = client.read_until_eose().await; + + assert_eq!(frames.len(), 2, "{frames:?}"); + assert_eq!( + frames[0], + format!( + r#"["EVENT","probe",{}]"#, + serde_json::to_string(&event).expect("event json") + ) + ); + assert_eq!(frames[1], r#"["EOSE","probe"]"#); +} + +#[tokio::test] +async fn a_re_req_replaces_the_filters_so_only_the_new_set_receives() { + let server = start(Fixture::new(settings())).await; + let (mut client, _challenge) = server.connect().await; + + client.send(r#"["REQ","s1",{"kinds":[4]}]"#).await; + assert_eq!(client.expect_frame().await, r#"["EOSE","s1"]"#); + client.send(r#"["REQ","s1",{"kinds":[1]}]"#).await; + assert_eq!(client.expect_frame().await, r#"["EOSE","s1"]"#); + + let event = signed_event(&CLIENT_SECRET, 1, vec![], "replaced", NOW_SECS); + + client + .send(&format!( + r#"["EVENT",{}]"#, + serde_json::to_string(&event).expect("event json") + )) + .await; + + assert_eq!( + client.expect_frame().await, + format!(r#"["OK","{}",true,""]"#, event.id) + ); + assert_eq!( + client.expect_frame().await, + format!( + r#"["EVENT","s1",{}]"#, + serde_json::to_string(&event).expect("event json") + ) + ); +} + +#[tokio::test] +async fn a_close_leaves_the_other_subscriptions_untouched() { + let server = start(Fixture::new(settings())).await; + let (mut client, _challenge) = server.connect().await; + + client.send(r#"["REQ","a",{"kinds":[1]}]"#).await; + assert_eq!(client.expect_frame().await, r#"["EOSE","a"]"#); + client.send(r#"["REQ","b",{"kinds":[1]}]"#).await; + assert_eq!(client.expect_frame().await, r#"["EOSE","b"]"#); + + client.send(r#"["CLOSE","a"]"#).await; + + let event = signed_event(&CLIENT_SECRET, 1, vec![], "half", NOW_SECS); + + client + .send(&format!( + r#"["EVENT",{}]"#, + serde_json::to_string(&event).expect("event json") + )) + .await; + + assert_eq!( + client.expect_frame().await, + format!(r#"["OK","{}",true,""]"#, event.id) + ); + assert_eq!( + client.expect_frame().await, + format!( + r#"["EVENT","b",{}]"#, + serde_json::to_string(&event).expect("event json") + ) + ); +} + +#[tokio::test] +async fn closing_the_socket_aborts_an_in_flight_read() { + // A gated read holds inside the repository until a permit is released, which is the + // window a socket close lands in. + let fixture = Fixture::new(settings()); + let gate = fixture.events.gate_reads(); + fixture + .seed_event(signed_event(&CLIENT_SECRET, 1, vec![], "stored", NOW_SECS)) + .await; + + let (sink, mut frames) = FrameSink::channel(); + let connection = Arc::new(Connection::new( + "127.0.0.1".to_owned(), + nostream_core::nip42::Nip42Session::with_challenge("challenge", 0), + )); + let context = Arc::new(fixture.context( + Arc::new(TestClock::new(NOW_MS)), + relay_keys(), + Arc::clone(&connection), + sink, + )); + + let reading = tokio::spawn({ + let context = Arc::clone(&context); + + async move { + nostream_relay::handlers::subscribe::handle( + SubscribeMessage { + subscription_id: "s1".to_owned(), + filters: vec![Filter { + kinds: Some(vec![nostream_core::Kind(1)]), + ..Default::default() + }], + }, + &context, + ) + .await; + } + }); + + // Let the handler register the subscription and reach the gated read. + for _ in 0..100 { + if connection.read_in_flight("s1") { + break; + } + + tokio::task::yield_now().await; + } + + assert!(connection.read_in_flight("s1"), "the read is in flight"); + + connection.close(); + gate.add_permits(1); + reading + .await + .expect("the handler finishes once the read is released"); + + assert!( + frames.try_recv().is_err(), + "a closed socket's read sends no frame" + ); + assert_eq!(connection.subscription_count(), 0); +} diff --git a/crates/nostream-relay/tests/handlers.rs b/crates/nostream-relay/tests/handlers.rs new file mode 100644 index 000000000..84bc1dab0 --- /dev/null +++ b/crates/nostream-relay/tests/handlers.rs @@ -0,0 +1,1469 @@ +//! The five handlers over a real socket. Carries the behaviour of +//! `test/unit/handlers/event-message-handler.spec.ts` (200 cases), +//! `subscribe-message-handler.spec.ts` (58), `count-message-handler.spec.ts` (10), +//! `auth-message-handler.spec.ts` (12) and `unsubscribe-message-handler.spec.ts` (1). +//! +//! Every assertion is a frame as received, byte for byte, which is what makes these a port +//! rather than an approximation. The EVENT pipeline's reason strings are pinned one test per +//! string, so `wire_parity -- event-rejections` can carry the same table to the differential +//! oracle. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod common; + +use std::time::Duration; + +use common::{CLIENT_SECRET, NOW_SECS, RELAY_SECRET, Server, relay_keys, signed_event}; +use nostream_config::settings::{ + ClientLimits, ClientSubscriptionLimits, ConnectionLimits, CreatedAtLimits, EventIdLimits, + EventLimits, EventRateLimit, KindLimits, Limits, MessageLimits, MessageRateLimit, + Nip05Settings, Payments, PubkeyLimits, RateLimit, +}; +use nostream_core::Event; +use nostream_relay::relay_key::RelayKeySource; +use nostream_relay::testing::{Fixture, nip42_settings, nip43_settings, nip45_settings, settings}; +use nostream_storage::repositories::User; + +/// The relay's public key for [`RELAY_SECRET`]. +fn relay_pubkey() -> nostream_core::Pubkey { + nostream_core::crypto::public_key(&RELAY_SECRET).expect("relay public key") +} + +/// The event frame a client sends for one signed event. +fn event_frame(event: &Event) -> String { + format!( + r#"["EVENT",{}]"#, + serde_json::to_string(event).expect("event json") + ) +} + +/// A settings document with a limit block. +fn settings_with(limits: Limits) -> nostream_config::Settings { + let mut settings = settings(); + settings.limits = Some(limits); + + settings +} + +fn event_limits(event: EventLimits) -> Limits { + Limits { + event: Some(event), + ..Default::default() + } +} + +/// Starts a relay with the test key source. +async fn start(fixture: Fixture) -> Server { + Server::start_with(fixture, relay_keys(), common::fast_config()).await +} + +/// Sends one EVENT and returns its OK frame. +async fn send_event(server: &Server, event: &Event) -> String { + let (mut client, _challenge) = server.connect().await; + + client.send(&event_frame(event)).await; + + client.expect_frame().await +} + +// --------------------------------------------------------------------------------------- +// EVENT (R9) +// --------------------------------------------------------------------------------------- + +#[tokio::test] +async fn event_id_mismatch_is_rejected() { + let server = start(Fixture::new(settings())).await; + let mut event = signed_event(&CLIENT_SECRET, 1, vec![], "hello", NOW_SECS); + event.content = "tampered".to_owned(); + + let frame = send_event(&server, &event).await; + + assert_eq!( + frame, + format!( + r#"["OK","{}",false,"invalid: event id does not match"]"#, + event.id + ) + ); +} + +#[tokio::test] +async fn event_signature_mismatch_is_rejected() { + let server = start(Fixture::new(settings())).await; + let mut event = signed_event(&CLIENT_SECRET, 1, vec![], "hello", NOW_SECS); + // Tamper after signing, then recompute the id so it matches: the signature no longer + // covers the id the relay checks. + event.content = "tampered".to_owned(); + event.id = nostream_core::crypto::compute_event_id(&event).expect("id"); + + let frame = send_event(&server, &event).await; + + assert_eq!( + frame, + format!( + r#"["OK","{}",false,"invalid: event signature verification failed"]"#, + event.id + ) + ); +} + +#[tokio::test] +async fn inner_kinds_must_be_gift_wrapped() { + let server = start(Fixture::new(settings())).await; + + for kind in [13u64, 14, 15, 444] { + let event = signed_event(&CLIENT_SECRET, kind, vec![], "", NOW_SECS); + let frame = send_event(&server, &event).await; + + assert_eq!( + frame, + format!( + r#"["OK","{}",false,"blocked: kind {kind} events must not be published directly; wrap them in a kind 1059 gift wrap"]"#, + event.id + ), + "kind {kind}" + ); + } +} + +#[tokio::test] +async fn auth_events_must_use_the_auth_message() { + let server = start(Fixture::new(settings())).await; + let event = signed_event(&CLIENT_SECRET, 22_242, vec![], "", NOW_SECS); + + let frame = send_event(&server, &event).await; + + assert_eq!( + frame, + format!( + r#"["OK","{}",false,"invalid: auth events must be sent using the AUTH message type"]"#, + event.id + ) + ); +} + +#[tokio::test] +async fn request_to_vanish_relay_tag_is_checked() { + let server = start(Fixture::new(settings())).await; + let event = signed_event( + &CLIENT_SECRET, + 62, + vec![vec!["r", "wss://somewhere-else.example"]], + "", + NOW_SECS, + ); + + let frame = send_event(&server, &event).await; + + assert_eq!( + frame, + format!( + r#"["OK","{}",false,"invalid: request to vanish relay tag invalid"]"#, + event.id + ) + ); +} + +#[tokio::test] +async fn an_expired_event_is_rejected() { + let server = start(Fixture::new(settings())).await; + let event = signed_event( + &CLIENT_SECRET, + 1, + vec![vec!["expiration", "100"]], + "", + NOW_SECS, + ); + + let frame = send_event(&server, &event).await; + + assert_eq!( + frame, + format!(r#"["OK","{}",false,"event is expired"]"#, event.id) + ); +} + +#[tokio::test] +async fn the_event_rate_limit_answers_with_the_backoff() { + let fixture = Fixture::new(settings_with(event_limits(EventLimits { + rate_limits: Some(vec![EventRateLimit { + description: None, + kinds: None, + period: 60_000, + rate: 1, + }]), + ..Default::default() + }))); + let server = start(fixture).await; + + let first = signed_event(&CLIENT_SECRET, 1, vec![], "one", NOW_SECS); + let second = signed_event(&CLIENT_SECRET, 1, vec![], "two", NOW_SECS); + + assert_eq!( + send_event(&server, &first).await, + format!(r#"["OK","{}",true,""]"#, first.id) + ); + assert_eq!( + send_event(&server, &second).await, + format!( + r#"["OK","{}",false,"rate-limited: slow down","60000"]"#, + second.id + ) + ); +} + +#[tokio::test] +async fn the_event_rate_limit_skips_a_non_matching_kind_and_the_relay_pubkey() { + let fixture = Fixture::new(settings_with(event_limits(EventLimits { + rate_limits: Some(vec![EventRateLimit { + description: None, + kinds: Some(vec![nostream_core::KindSelector::Single( + nostream_core::Kind(1), + )]), + period: 60_000, + rate: 1, + }]), + ..Default::default() + }))); + let server = start(fixture).await; + + let note = signed_event(&CLIENT_SECRET, 1, vec![], "one", NOW_SECS); + let other = signed_event(&CLIENT_SECRET, 2, vec![], "relay", NOW_SECS); + + assert_eq!( + send_event(&server, ¬e).await, + format!(r#"["OK","{}",true,""]"#, note.id) + ); + assert_eq!( + send_event(&server, &other).await, + format!(r#"["OK","{}",true,""]"#, other.id) + ); + assert_eq!( + send_event(&server, ¬e).await, + format!( + r#"["OK","{}",false,"rate-limited: slow down","60000"]"#, + note.id + ) + ); +} + +#[tokio::test] +async fn the_content_limit_is_bytes_of_utf16() { + let fixture = Fixture::new(settings_with(event_limits(EventLimits { + content: Some(nostream_config::settings::ContentLimitsOrVec::One( + nostream_config::settings::ContentLimits { + description: None, + kinds: None, + max_length: Some(5), + }, + )), + ..Default::default() + }))); + let server = start(fixture).await; + let event = signed_event(&CLIENT_SECRET, 1, vec![], "sixsix", NOW_SECS); + + let frame = send_event(&server, &event).await; + + assert_eq!( + frame, + format!( + r#"["OK","{}",false,"rejected: content is longer than 5 bytes"]"#, + event.id + ) + ); +} + +#[tokio::test] +async fn created_at_deltas_are_enforced_in_both_directions() { + let fixture = Fixture::new(settings_with(event_limits(EventLimits { + created_at: Some(CreatedAtLimits { + max_negative_delta: Some(100), + max_positive_delta: Some(100), + }), + ..Default::default() + }))); + let server = start(fixture).await; + + let future = signed_event(&CLIENT_SECRET, 1, vec![], "", NOW_SECS + 101); + let past = signed_event(&CLIENT_SECRET, 1, vec![], "", NOW_SECS - 101); + let inside = signed_event(&CLIENT_SECRET, 1, vec![], "", NOW_SECS + 100); + + assert_eq!( + send_event(&server, &future).await, + format!( + r#"["OK","{}",false,"rejected: created_at is more than 100 seconds in the future"]"#, + future.id + ) + ); + assert_eq!( + send_event(&server, &past).await, + format!( + r#"["OK","{}",false,"rejected: created_at is more than 100 seconds in the past"]"#, + past.id + ) + ); + assert_eq!( + send_event(&server, &inside).await, + format!(r#"["OK","{}",true,""]"#, inside.id) + ); +} + +#[tokio::test] +async fn the_static_proof_of_work_requirements_are_enforced() { + let fixture = Fixture::new(settings_with(event_limits(EventLimits { + event_id: Some(EventIdLimits { + min_leading_zero_bits: Some(256), + }), + ..Default::default() + }))); + let server = start(fixture).await; + let event = signed_event(&CLIENT_SECRET, 1, vec![], "", NOW_SECS); + + let frame = send_event(&server, &event).await; + + let observed = nostream_core::pow::leading_zero_bits(event.id.as_bytes()); + assert!( + frame.ends_with(&format!(r#"false,"pow: difficulty {observed}<256"]"#)), + "{frame}" + ); + + let fixture = Fixture::new(settings_with(event_limits(EventLimits { + pubkey: Some(PubkeyLimits { + min_balance: 0, + min_leading_zero_bits: 256, + whitelist: None, + blacklist: None, + }), + ..Default::default() + }))); + let server = start(fixture).await; + let frame = send_event(&server, &event).await; + + let observed = nostream_core::pow::leading_zero_bits(event.pubkey.as_bytes()); + assert!( + frame.ends_with(&format!( + r#"false,"pow: pubkey difficulty {observed}<256"]"# + )), + "{frame}" + ); +} + +#[tokio::test] +async fn pubkey_whitelist_and_blacklist_block() { + use nostream_core::SettingsPubkey; + + let other = nostream_core::crypto::public_key(&CLIENT_SECRET) + .expect("pubkey") + .to_string(); + let fixture = Fixture::new(settings_with(event_limits(EventLimits { + pubkey: Some(PubkeyLimits { + min_balance: 0, + min_leading_zero_bits: 0, + whitelist: Some(vec![SettingsPubkey("00".repeat(32))]), + blacklist: None, + }), + ..Default::default() + }))); + let server = start(fixture).await; + let event = signed_event(&CLIENT_SECRET, 1, vec![], "", NOW_SECS); + + assert!( + send_event(&server, &event) + .await + .ends_with(r#"false,"blocked: pubkey not allowed"]"#) + ); + + let fixture = Fixture::new(settings_with(event_limits(EventLimits { + pubkey: Some(PubkeyLimits { + min_balance: 0, + min_leading_zero_bits: 0, + whitelist: None, + blacklist: Some(vec![SettingsPubkey(other)]), + }), + ..Default::default() + }))); + let server = start(fixture).await; + + assert!( + send_event(&server, &event) + .await + .ends_with(r#"false,"blocked: pubkey not allowed"]"#) + ); +} + +#[tokio::test] +async fn kind_whitelist_and_blacklist_block() { + use nostream_core::Kind; + use nostream_core::KindSelector; + + let fixture = Fixture::new(settings_with(event_limits(EventLimits { + kind: Some(KindLimits { + whitelist: Some(vec![KindSelector::Single(Kind(0))]), + blacklist: None, + }), + ..Default::default() + }))); + let server = start(fixture).await; + let event = signed_event(&CLIENT_SECRET, 1, vec![], "", NOW_SECS); + + assert!( + send_event(&server, &event) + .await + .ends_with(r#"false,"blocked: event kind 1 not allowed"]"#) + ); + + let fixture = Fixture::new(settings_with(event_limits(EventLimits { + kind: Some(KindLimits { + whitelist: None, + blacklist: Some(vec![KindSelector::Range([Kind(1), Kind(2)])]), + }), + ..Default::default() + }))); + let server = start(fixture).await; + + assert!( + send_event(&server, &event) + .await + .ends_with(r#"false,"blocked: event kind 1 not allowed"]"#) + ); +} + +#[tokio::test] +async fn authentication_can_be_required_to_publish() { + let mut settings = settings(); + let mut nip42 = nip42_settings(); + nip42.auth_required = Some(true); + settings.nip42 = Some(nip42); + + let server = start(Fixture::new(settings)).await; + let event = signed_event(&CLIENT_SECRET, 1, vec![], "", NOW_SECS); + + assert_eq!( + send_event(&server, &event).await, + format!( + r#"["OK","{}",false,"auth-required: authentication is required to publish events"]"#, + event.id + ) + ); +} + +#[tokio::test] +async fn a_protected_event_needs_its_author() { + let server = start(Fixture::new(settings())).await; + let event = signed_event(&CLIENT_SECRET, 1, vec![vec!["-"]], "", NOW_SECS); + + assert_eq!( + send_event(&server, &event).await, + format!( + r#"["OK","{}",false,"auth-required: this event may only be published by its author"]"#, + event.id + ) + ); +} + +#[tokio::test] +async fn a_repost_may_not_embed_a_protected_event() { + let server = start(Fixture::new(settings())).await; + let inner = signed_event(&CLIENT_SECRET, 1, vec![vec!["-"]], "inner", NOW_SECS); + let repost = signed_event( + &CLIENT_SECRET, + 6, + vec![], + &serde_json::to_string(&inner).expect("inner json"), + NOW_SECS, + ); + + assert_eq!( + send_event(&server, &repost).await, + format!( + r#"["OK","{}",false,"blocked: reposts must not embed protected events"]"#, + repost.id + ) + ); +} + +#[tokio::test] +async fn a_vanished_pubkey_is_blocked() { + let fixture = Fixture::new(settings()); + let pubkey = nostream_core::crypto::public_key(&CLIENT_SECRET).expect("pubkey"); + let now = chrono::Utc::now(); + fixture.users.insert(User { + pubkey, + is_admitted: true, + is_vanished: true, + balance: 0, + created_at: now, + updated_at: now, + }); + + let server = start(fixture).await; + let event = signed_event(&CLIENT_SECRET, 1, vec![], "", NOW_SECS); + + assert_eq!( + send_event(&server, &event).await, + format!( + r#"["OK","{}",false,"blocked: request to vanish active for pubkey"]"#, + event.id + ) + ); +} + +#[tokio::test] +async fn nip43_admission_is_gated_and_has_its_own_reason() { + let mut settings = settings(); + settings.nip43 = Some(nip43_settings()); + + let server = start(Fixture::new(settings)).await; + + let event = signed_event(&CLIENT_SECRET, 1, vec![], "", NOW_SECS); + + assert_eq!( + send_event(&server, &event).await, + format!( + r#"["OK","{}",false,"restricted: pubkey not admitted; send a join request (kind 28934) with a valid invite code"]"#, + event.id + ) + ); +} + +#[tokio::test] +async fn an_admitted_pubkey_publishes_and_a_low_balance_blocks() { + let mut settings = settings(); + settings.payments = Some(Payments { + enabled: true, + processor: nostream_config::settings::Processor::Lnurl, + fee_schedules: nostream_config::settings::FeeSchedules { + admission: vec![nostream_config::settings::FeeSchedule { + enabled: true, + description: None, + amount: 1_000, + whitelists: None, + }], + publication: None, + }, + invoice_expiry_seconds: None, + }); + settings.limits = Some(event_limits(EventLimits { + pubkey: Some(PubkeyLimits { + min_balance: 100, + min_leading_zero_bits: 0, + whitelist: None, + blacklist: None, + }), + ..Default::default() + })); + + let fixture = Fixture::new(settings); + let server = start(fixture).await; + let pubkey = nostream_core::crypto::public_key(&CLIENT_SECRET).expect("pubkey"); + let event = signed_event(&CLIENT_SECRET, 1, vec![], "", NOW_SECS); + + assert_eq!( + send_event(&server, &event).await, + format!( + r#"["OK","{}",false,"blocked: pubkey not admitted"]"#, + event.id + ) + ); + + let now = chrono::Utc::now(); + server.fixture.users.insert(User { + pubkey, + is_admitted: true, + is_vanished: false, + balance: 0, + created_at: now, + updated_at: now, + }); + + assert_eq!( + send_event(&server, &event).await, + format!( + r#"["OK","{}",false,"blocked: insufficient balance"]"#, + event.id + ) + ); + + server.fixture.users.insert(User { + pubkey, + is_admitted: true, + is_vanished: false, + balance: 1_000, + created_at: now, + updated_at: now, + }); + + assert_eq!( + send_event(&server, &event).await, + format!(r#"["OK","{}",true,""]"#, event.id) + ); +} + +#[tokio::test] +async fn nip05_gates_publication() { + let mut settings = settings(); + settings.nip05 = Some(Nip05Settings { + mode: nostream_config::settings::Nip05Mode::Enabled, + verify_expiration: None, + verify_update_frequency: None, + max_consecutive_failures: None, + domain_whitelist: Some(vec!["allowed.example".to_owned()]), + domain_blacklist: None, + }); + + let server = start(Fixture::new(settings)).await; + let event = signed_event(&CLIENT_SECRET, 1, vec![], "", NOW_SECS); + + assert_eq!( + send_event(&server, &event).await, + format!( + r#"["OK","{}",false,"blocked: NIP-05 verification required"]"#, + event.id + ) + ); + + let pubkey = nostream_core::crypto::public_key(&CLIENT_SECRET).expect("pubkey"); + let now = chrono::Utc::now(); + let verification = nostream_storage::repositories::Nip05Verification { + pubkey, + nip05: "someone@denied.example".to_owned(), + domain: "denied.example".to_owned(), + is_verified: true, + last_verified_at: Some(now), + last_checked_at: now, + failure_count: 0, + created_at: now, + updated_at: now, + }; + server.fixture.nip05.insert(verification.clone()); + + assert_eq!( + send_event(&server, &event).await, + format!( + r#"["OK","{}",false,"blocked: NIP-05 domain not allowed"]"#, + event.id + ) + ); + + let allowed = nostream_storage::repositories::Nip05Verification { + domain: "allowed.example".to_owned(), + nip05: "someone@allowed.example".to_owned(), + last_verified_at: Some( + chrono::DateTime::from_timestamp(NOW_SECS - 604_801, 0).expect("instant"), + ), + ..verification + }; + server.fixture.nip05.insert(allowed); + + assert_eq!( + send_event(&server, &event).await, + format!( + r#"["OK","{}",false,"blocked: NIP-05 verification expired"]"#, + event.id + ) + ); + + let fresh = nostream_storage::repositories::Nip05Verification { + last_verified_at: Some(chrono::DateTime::from_timestamp(NOW_SECS, 0).expect("instant")), + ..nostream_storage::repositories::Nip05Verification { + pubkey, + nip05: "someone@allowed.example".to_owned(), + domain: "allowed.example".to_owned(), + is_verified: true, + last_verified_at: None, + last_checked_at: now, + failure_count: 0, + created_at: now, + updated_at: now, + } + }; + server.fixture.nip05.insert(fresh); + + assert_eq!( + send_event(&server, &event).await, + format!(r#"["OK","{}",true,""]"#, event.id) + ); +} + +#[tokio::test] +async fn the_default_strategy_answers_ok_and_duplicate() { + let server = start(Fixture::new(settings())).await; + let event = signed_event(&CLIENT_SECRET, 1, vec![], "hello", NOW_SECS); + + assert_eq!( + send_event(&server, &event).await, + format!(r#"["OK","{}",true,""]"#, event.id) + ); + assert_eq!( + send_event(&server, &event).await, + format!(r#"["OK","{}",true,"duplicate:"]"#, event.id) + ); + assert_eq!(server.fixture.events.events().len(), 1); +} + +#[tokio::test] +async fn without_a_key_an_event_cannot_be_processed() { + let server = Server::start_with( + Fixture::new(settings()), + RelayKeySource::from_values(None, None), + common::fast_config(), + ) + .await; + let event = signed_event(&CLIENT_SECRET, 1, vec![], "", NOW_SECS); + + assert_eq!( + send_event(&server, &event).await, + format!( + r#"["OK","{}",false,"error: unable to process event"]"#, + event.id + ) + ); +} + +#[tokio::test] +async fn the_relay_pubkey_bypasses_the_acceptance_gates() { + let mut settings = settings(); + settings.limits = Some(event_limits(EventLimits { + rate_limits: Some(vec![EventRateLimit { + description: None, + kinds: None, + period: 60_000, + rate: 1, + }]), + ..Default::default() + })); + + let server = start(Fixture::new(settings)).await; + let event = signed_event(&RELAY_SECRET, 1, vec![], "from the relay", NOW_SECS); + + assert_eq!( + send_event(&server, &event).await, + format!(r#"["OK","{}",true,""]"#, event.id) + ); + assert_eq!( + send_event(&server, &event).await, + format!(r#"["OK","{}",true,"duplicate:"]"#, event.id) + ); + + // The relay's own key is the one `relay_keys` supplies. + assert_eq!( + relay_pubkey(), + nostream_core::crypto::public_key(&RELAY_SECRET).expect("pubkey") + ); +} + +// --------------------------------------------------------------------------------------- +// REQ (R10) +// --------------------------------------------------------------------------------------- + +#[tokio::test] +async fn req_streams_a_stored_event_then_eose() { + let stored = signed_event(&CLIENT_SECRET, 1, vec![], "stored", NOW_SECS); + let fixture = Fixture::new(settings()); + let server = start(fixture).await; + + let (mut client, _challenge) = server.connect().await; + // Accept an event, then read it back. + client.send(&event_frame(&stored)).await; + assert!(client.expect_frame().await.ends_with(r#"true,""]"#)); + + client.send(r#"["REQ","s2",{"kinds":[1]}]"#).await; + let frames = client.read_until_eose().await; + + assert_eq!( + frames, + vec![ + format!( + r#"["EVENT","s2",{}]"#, + serde_json::to_string(&stored).expect("event json") + ), + r#"["EOSE","s2"]"#.to_owned(), + ] + ); +} + +#[tokio::test] +async fn a_duplicate_req_is_refused_with_the_capture_string() { + let server = start(Fixture::new(settings())).await; + let (mut client, _challenge) = server.connect().await; + + client.send(r#"["REQ","s1",{"kinds":[1]}]"#).await; + assert_eq!(client.expect_frame().await, r#"["EOSE","s1"]"#); + + client.send(r#"["REQ","s1",{"kinds":[1]}]"#).await; + + assert_eq!( + client.expect_frame().await, + r#"["NOTICE","Subscription rejected: Duplicate subscription s1: Ignoring"]"# + ); +} + +#[tokio::test] +async fn the_subscription_count_limit_is_the_capture_string() { + let limits = Limits { + client: Some(ClientLimits { + subscription: Some(ClientSubscriptionLimits { + max_subscriptions: Some(1), + ..Default::default() + }), + }), + ..Default::default() + }; + let server = start(Fixture::new(settings_with(limits))).await; + let (mut client, _challenge) = server.connect().await; + + client.send(r#"["REQ","s1",{"kinds":[1]}]"#).await; + assert_eq!(client.expect_frame().await, r#"["EOSE","s1"]"#); + + client.send(r#"["REQ","s2",{"kinds":[1]}]"#).await; + assert_eq!( + client.expect_frame().await, + r#"["NOTICE","Subscription rejected: Too many subscriptions: Number of subscriptions must be less than or equal to 1"]"# + ); +} + +#[tokio::test] +async fn the_filter_limits_carry_the_captures_strings_and_typos() { + let limits = Limits { + client: Some(ClientLimits { + subscription: Some(ClientSubscriptionLimits { + max_filters: Some(1), + max_filter_values: Some(2), + max_limit: Some(1), + max_subscription_id_length: Some(5), + ..Default::default() + }), + }), + ..Default::default() + }; + let server = start(Fixture::new(settings_with(limits))).await; + let (mut client, _challenge) = server.connect().await; + + // The first REQ registers; the rest share its id so the count limit cannot pre-empt them. + client.send(r#"["REQ","s1",{"kinds":[1]}]"#).await; + assert_eq!(client.expect_frame().await, r#"["EOSE","s1"]"#); + + // Too many filters, with the capture's `susbscription` and `less then`. + client + .send(r#"["REQ","s1",{"kinds":[1]},{"kinds":[2]}]"#) + .await; + assert_eq!( + client.expect_frame().await, + r#"["NOTICE","Subscription rejected: Too many filters: Number of filters per susbscription must be less then or equal to 1"]"# + ); + + // Too many filter values. + client.send(r#"["REQ","s1",{"kinds":[1,2,3]}]"#).await; + assert_eq!( + client.expect_frame().await, + r#"["NOTICE","Subscription rejected: Too many filter values: Number of values per filter must be less than or equal to 2"]"# + ); + + // Limit too high. + client.send(r#"["REQ","s1",{"kinds":[1],"limit":2}]"#).await; + assert_eq!( + client.expect_frame().await, + r#"["NOTICE","Subscription rejected: Limit too high: Filter limit must be less than or equal to 1"]"# + ); + + // Subscription id too long, with the capture's `less or equal`. + client.send(r#"["REQ","abcdef",{"kinds":[1]}]"#).await; + assert_eq!( + client.expect_frame().await, + r#"["NOTICE","Subscription rejected: Subscription ID too long: Subscription ID must be less or equal to 5"]"# + ); +} + +#[tokio::test] +async fn a_restricted_only_req_from_an_unauthenticated_client_is_closed() { + let mut settings = settings(); + settings.nip42 = Some(nip42_settings()); + let server = start(Fixture::new(settings)).await; + let (mut client, _challenge) = server.connect().await; + + client.send(r#"["REQ","s1",{"kinds":[4,1059]}]"#).await; + + assert_eq!( + client.expect_frame().await, + r#"["CLOSED","s1","auth-required: authentication is required to request these event kinds"]"# + ); +} + +#[tokio::test] +async fn a_restricted_read_delivers_only_to_the_author_or_a_p_tagged_reader() { + let mut settings = settings(); + settings.nip42 = Some(nip42_settings()); + let server = start(Fixture::new(settings)).await; + + let author = signed_event(&CLIENT_SECRET, 4, vec![], "ciphertext", NOW_SECS); + let (mut writer, _challenge) = server.connect().await; + writer.send(&event_frame(&author)).await; + assert!(writer.expect_frame().await.ends_with(r#"true,""]"#)); + + // A restricted-only REQ from an unauthenticated client is closed, not served empty. + let (mut stranger, _challenge) = server.connect().await; + stranger.send(r#"["REQ","s1",{"kinds":[4]}]"#).await; + assert_eq!( + stranger.expect_frame().await, + r#"["CLOSED","s1","auth-required: authentication is required to request these event kinds"]"# + ); + + // The author authenticates and reads their own event. + let (mut owner, challenge) = server.connect().await; + let auth = auth_event(&challenge, NOW_SECS); + owner + .send(&format!( + r#"["AUTH",{}]"#, + serde_json::to_string(&auth).expect("auth json") + )) + .await; + assert!(owner.expect_frame().await.ends_with(r#"true,""]"#)); + + owner.send(r#"["REQ","mine",{"kinds":[4]}]"#).await; + let frames = owner.read_until_eose().await; + + assert_eq!(frames.len(), 2, "{frames:?}"); + assert!(frames[0].starts_with(r#"["EVENT","mine","#)); + assert_eq!(frames[1], r#"["EOSE","mine"]"#); +} + +#[tokio::test] +async fn a_close_aborts_an_in_flight_read_without_a_frame() { + let stored = signed_event(&CLIENT_SECRET, 1, vec![], "stored", NOW_SECS); + let fixture = Fixture::new(settings()); + let gate = fixture.events.gate_reads(); + fixture.seed_event(stored).await; + + let server = start(fixture).await; + let (mut client, _challenge) = server.connect().await; + + client.send(r#"["REQ","s1",{"kinds":[1]}]"#).await; + tokio::time::sleep(Duration::from_millis(50)).await; + client.send(r#"["CLOSE","s1"]"#).await; + tokio::time::sleep(Duration::from_millis(50)).await; + + // Nothing was emitted for the aborted read: no EVENT and no EOSE. + assert!(client.next_message().await.is_none()); + + // The subscription is gone, so a fresh REQ under the same id is not a duplicate. + gate.add_permits(4); + client.send(r#"["REQ","s1",{"kinds":[1]}]"#).await; + let frames = client.read_until_eose().await; + + assert_eq!(frames.len(), 2, "{frames:?}"); + assert_eq!(frames[1], r#"["EOSE","s1"]"#); +} + +#[tokio::test] +async fn a_new_filter_set_replaces_the_subscription() { + let fixture = Fixture::new(settings()); + let note = signed_event(&CLIENT_SECRET, 1, vec![], "note", NOW_SECS); + let metadata = signed_event(&CLIENT_SECRET, 0, vec![], "{}", NOW_SECS); + fixture.seed_event(note).await; + fixture.seed_event(metadata.clone()).await; + + let server = start(fixture).await; + let (mut client, _challenge) = server.connect().await; + + client.send(r#"["REQ","s1",{"kinds":[1]}]"#).await; + assert_eq!(client.read_until_eose().await.len(), 2); + + // Different filters under the same id: accepted, and the new set decides the stream. + client.send(r#"["REQ","s1",{"kinds":[0]}]"#).await; + let frames = client.read_until_eose().await; + + assert_eq!(frames.len(), 2, "{frames:?}"); + assert!(frames[0].contains(&metadata.id.to_string())); +} + +#[tokio::test] +async fn an_invite_request_mints_one_kind_28935_event_before_eose() { + let mut settings = settings(); + settings.nip43 = Some(nip43_settings()); + let server = start(Fixture::new(settings)).await; + let (mut client, challenge) = server.connect().await; + + let auth = auth_event(&challenge, NOW_SECS); + client + .send(&format!( + r#"["AUTH",{}]"#, + serde_json::to_string(&auth).expect("auth json") + )) + .await; + assert!(client.expect_frame().await.ends_with(r#"true,""]"#)); + + client.send(r#"["REQ","invite",{"kinds":[28935]}]"#).await; + let frames = client.read_until_eose().await; + + assert_eq!(frames.len(), 2, "{frames:?}"); + + let event: serde_json::Value = serde_json::from_str( + frames[0] + .strip_prefix(r#"["EVENT","invite","#) + .and_then(|rest| rest.strip_suffix(']')) + .expect("an EVENT frame"), + ) + .expect("event json"); + + assert_eq!(event["kind"], 28_935); + let tags = event["tags"].as_array().expect("tags"); + assert!(tags.iter().any(|tag| tag[0] == "claim")); + assert!(tags.iter().any(|tag| tag[0] == "-")); + assert!(tags.iter().any(|tag| tag[0] == "expiration")); + assert_eq!( + event["created_at"].as_i64(), + Some(NOW_SECS), + "created_at is the frozen clock" + ); + assert_eq!(server.fixture.invites.minted().len(), 1); + assert_eq!(frames[1], r#"["EOSE","invite"]"#); +} + +#[tokio::test] +async fn an_unauthenticated_invite_request_gets_eose_only() { + let mut settings = settings(); + settings.nip43 = Some(nip43_settings()); + let server = start(Fixture::new(settings)).await; + let (mut client, _challenge) = server.connect().await; + + client.send(r#"["REQ","invite",{"kinds":[28935]}]"#).await; + + assert_eq!( + client.read_until_eose().await, + vec!["[\"EOSE\",\"invite\"]".to_owned()] + ); + assert!(server.fixture.invites.minted().is_empty()); +} + +// --------------------------------------------------------------------------------------- +// COUNT (R12) +// --------------------------------------------------------------------------------------- + +#[tokio::test] +async fn count_answers_with_the_count_object() { + let server = start(Fixture::new(settings())).await; + let (mut client, _challenge) = server.connect().await; + + client.send(r#"["COUNT","c1",{"kinds":[1]}]"#).await; + + assert_eq!(client.expect_frame().await, r#"["COUNT","c1",{"count":0}]"#); +} + +#[tokio::test] +async fn count_can_be_disabled_by_configuration() { + let mut settings = settings(); + settings.nip45 = Some(nip45_settings()); + settings.nip45 = Some(nostream_config::settings::Nip45Settings { + enabled: Some(false), + }); + let server = start(Fixture::new(settings)).await; + let (mut client, _challenge) = server.connect().await; + + client.send(r#"["COUNT","c1",{"kinds":[1]}]"#).await; + + assert_eq!( + client.expect_frame().await, + r#"["CLOSED","c1","COUNT is disabled by relay configuration"]"# + ); +} + +#[tokio::test] +async fn count_limits_are_the_capture_strings() { + let limits = Limits { + client: Some(ClientLimits { + subscription: Some(ClientSubscriptionLimits { + max_subscriptions: None, + max_filters: Some(1), + max_filter_values: Some(2), + max_limit: None, + min_prefix_length: None, + max_subscription_id_length: Some(3), + }), + }), + ..Default::default() + }; + let server = start(Fixture::new(settings_with(limits))).await; + let (mut client, _challenge) = server.connect().await; + + client + .send(r#"["COUNT","c1",{"kinds":[1]},{"kinds":[0]}]"#) + .await; + assert_eq!( + client.expect_frame().await, + r#"["CLOSED","c1","Too many filters: Number of filters per count query must be less than or equal to 1"]"# + ); + + client.send(r#"["COUNT","c1",{"kinds":[1,2,3]}]"#).await; + assert_eq!( + client.expect_frame().await, + r#"["CLOSED","c1","Too many filter values: Number of values per filter must be less than or equal to 2"]"# + ); + + client.send(r#"["COUNT","four",{"kinds":[1]}]"#).await; + assert_eq!( + client.expect_frame().await, + r#"["CLOSED","four","Query ID too long: Query ID must be less than or equal to 3"]"# + ); +} + +#[tokio::test] +async fn a_count_repository_failure_is_generic() { + let fixture = Fixture::new(settings()); + fixture.events.fail_count(); + let server = start(fixture).await; + let (mut client, _challenge) = server.connect().await; + + client.send(r#"["COUNT","c1",{"kinds":[1]}]"#).await; + + assert_eq!( + client.expect_frame().await, + r#"["CLOSED","c1","error: unable to count events"]"# + ); +} + +#[tokio::test] +async fn a_restricted_count_needs_a_scoped_authors_filter() { + let mut settings = settings(); + settings.nip42 = Some(nip42_settings()); + let server = start(Fixture::new(settings)).await; + let (mut client, _challenge) = server.connect().await; + + client.send(r#"["COUNT","c1",{"kinds":[4]}]"#).await; + + assert_eq!( + client.expect_frame().await, + r#"["CLOSED","c1","auth-required: authentication is required to count these event kinds"]"# + ); +} + +// --------------------------------------------------------------------------------------- +// AUTH (R13) +// --------------------------------------------------------------------------------------- + +/// A kind-22242 event answering `challenge` for the relay URL. +fn auth_event(challenge: &str, created_at: i64) -> Event { + signed_event( + &CLIENT_SECRET, + 22_242, + vec![ + vec!["relay", "wss://relay.example.com"], + vec!["challenge", challenge], + ], + "", + created_at, + ) +} + +#[tokio::test] +async fn auth_outcomes_are_the_captures_seven() { + let server = start(Fixture::new(settings())).await; + let (mut client, challenge) = server.connect().await; + + // 1. wrong kind + let wrong_kind = signed_event(&CLIENT_SECRET, 1, vec![], "", NOW_SECS); + client + .send(&format!( + r#"["AUTH",{}]"#, + serde_json::to_string(&wrong_kind).expect("json") + )) + .await; + assert_eq!( + client.expect_frame().await, + format!( + r#"["OK","{}",false,"invalid: auth event must be kind 22242"]"#, + wrong_kind.id + ) + ); + + // 2. id does not match + let mut bad_id = auth_event(&challenge, NOW_SECS); + bad_id.content = "tampered".to_owned(); + client + .send(&format!( + r#"["AUTH",{}]"#, + serde_json::to_string(&bad_id).expect("json") + )) + .await; + assert_eq!( + client.expect_frame().await, + format!( + r#"["OK","{}",false,"invalid: event id does not match"]"#, + bad_id.id + ) + ); + + // 3. signature does not verify + let mut bad_sig = auth_event(&challenge, NOW_SECS); + bad_sig.content = "tampered".to_owned(); + bad_sig.id = nostream_core::crypto::compute_event_id(&bad_sig).expect("id"); + client + .send(&format!( + r#"["AUTH",{}]"#, + serde_json::to_string(&bad_sig).expect("json") + )) + .await; + assert_eq!( + client.expect_frame().await, + format!( + r#"["OK","{}",false,"invalid: event signature verification failed"]"#, + bad_sig.id + ) + ); + + // 4. created_at too far from the clock + let stale = auth_event(&challenge, NOW_SECS - 601); + client + .send(&format!( + r#"["AUTH",{}]"#, + serde_json::to_string(&stale).expect("json") + )) + .await; + assert_eq!( + client.expect_frame().await, + format!( + r#"["OK","{}",false,"invalid: created_at is too far from the current time"]"#, + stale.id + ) + ); + + // 5. challenge does not match + let wrong_challenge = auth_event("not-the-challenge", NOW_SECS); + client + .send(&format!( + r#"["AUTH",{}]"#, + serde_json::to_string(&wrong_challenge).expect("json") + )) + .await; + assert_eq!( + client.expect_frame().await, + format!( + r#"["OK","{}",false,"invalid: challenge does not match"]"#, + wrong_challenge.id + ) + ); + + // 6. relay url does not match + let wrong_relay = signed_event( + &CLIENT_SECRET, + 22_242, + vec![ + vec!["relay", "wss://elsewhere.example"], + vec!["challenge", &challenge], + ], + "", + NOW_SECS, + ); + client + .send(&format!( + r#"["AUTH",{}]"#, + serde_json::to_string(&wrong_relay).expect("json") + )) + .await; + assert_eq!( + client.expect_frame().await, + format!( + r#"["OK","{}",false,"invalid: relay url does not match"]"#, + wrong_relay.id + ) + ); + + // 7. accepted, then replayed + let good = auth_event(&challenge, NOW_SECS); + client + .send(&format!( + r#"["AUTH",{}]"#, + serde_json::to_string(&good).expect("json") + )) + .await; + assert_eq!( + client.expect_frame().await, + format!(r#"["OK","{}",true,""]"#, good.id) + ); + + client + .send(&format!( + r#"["AUTH",{}]"#, + serde_json::to_string(&good).expect("json") + )) + .await; + assert_eq!( + client.expect_frame().await, + format!( + r#"["OK","{}",false,"invalid: auth event already used"]"#, + good.id + ) + ); +} + +#[tokio::test] +async fn the_challenge_is_never_rotated_by_auth() { + let server = start(Fixture::new(settings())).await; + let (mut client, challenge) = server.connect().await; + + let auth = auth_event(&challenge, NOW_SECS); + client + .send(&format!( + r#"["AUTH",{}]"#, + serde_json::to_string(&auth).expect("json") + )) + .await; + assert!(client.expect_frame().await.ends_with(r#"true,""]"#)); + + // A second AUTH for another pubkey still uses the first challenge. + let second = auth_event(&challenge, NOW_SECS); + client + .send(&format!( + r#"["AUTH",{}]"#, + serde_json::to_string(&second).expect("json") + )) + .await; + + assert!( + client + .expect_frame() + .await + .contains("auth event already used") + ); +} + +#[tokio::test] +async fn nip42_sessions_expire_at_the_ttl_boundary() { + use nostream_core::nip42::Nip42Session; + + let mut session = Nip42Session::with_challenge("challenge", 60); + let pubkey = nostream_core::crypto::public_key(&CLIENT_SECRET).expect("pubkey"); + let event_id = nostream_core::EventId::from_byte_array([3; 32]); + + assert!(session.authenticate(pubkey, event_id, 1_000)); + assert!( + !session.authenticate(pubkey, event_id, 1_000), + "replay is refused" + ); + + assert!(session.is_authenticated(&pubkey, 1_059)); + assert!( + !session.is_authenticated(&pubkey, 1_060), + "expiry is inclusive" + ); + + let mut forever = Nip42Session::with_challenge("challenge", 0); + assert!(forever.authenticate(pubkey, event_id, 0)); + assert!(forever.is_authenticated(&pubkey, i64::MAX)); +} + +// --------------------------------------------------------------------------------------- +// Dispatch and the message limiter (R14) +// --------------------------------------------------------------------------------------- + +/// The settings that trip every message on its first hit. +fn tripping_message_limits() -> Limits { + Limits { + message: Some(MessageLimits { + rate_limits: Some(vec![MessageRateLimit { + description: None, + message_type: None, + period: 60_000, + rate: 0, + }]), + ip_whitelist: None, + }), + ..Default::default() + } +} + +#[tokio::test] +async fn the_message_limiter_closes_reads_and_notices_everything_else() { + let server = start(Fixture::new(settings_with(tripping_message_limits()))).await; + let (mut client, _challenge) = server.connect().await; + + client.send(r#"["REQ","s1",{"kinds":[1]}]"#).await; + assert_eq!( + client.expect_frame().await, + r#"["CLOSED","s1","rate-limited: slow down","60000"]"# + ); + + client.send(r#"["COUNT","c1",{"kinds":[1]}]"#).await; + assert_eq!( + client.expect_frame().await, + r#"["CLOSED","c1","rate-limited: slow down","60000"]"# + ); + + let event = signed_event(&CLIENT_SECRET, 1, vec![], "", NOW_SECS); + client.send(&event_frame(&event)).await; + assert_eq!(client.expect_frame().await, r#"["NOTICE","rate limited"]"#); + + client.send("not json").await; + assert_eq!(client.expect_frame().await, r#"["NOTICE","rate limited"]"#); +} + +#[tokio::test] +async fn a_whitelisted_ip_bypasses_the_message_limiter() { + let mut limits = tripping_message_limits(); + + if let Some(message) = limits.message.as_mut() { + message.ip_whitelist = Some(vec!["127.0.0.1".to_owned()]); + } + + let server = start(Fixture::new(settings_with(limits))).await; + let (mut client, _challenge) = server.connect().await; + + client.send(r#"["REQ","s1",{"kinds":[1]}]"#).await; + + assert_eq!(client.expect_frame().await, r#"["EOSE","s1"]"#); +} + +#[tokio::test] +async fn the_connection_limiter_terminates_the_upgrade() { + let limits = Limits { + connection: Some(ConnectionLimits { + rate_limits: vec![RateLimit { + description: None, + period: 60_000, + rate: 0, + }], + ip_whitelist: None, + ip_blacklist: None, + }), + ..Default::default() + }; + let server = start(Fixture::new(settings_with(limits))).await; + + let mut client = common::Client::connect(server.address).await; + + // The connection is terminated rather than admitted: nothing is served on it. + assert!(client.next_message_lossy().await.is_none()); +} + +#[tokio::test] +async fn an_invalid_frame_gets_the_capture_notice() { + let server = start(Fixture::new(settings())).await; + let (mut client, _challenge) = server.connect().await; + + client.send(r#"["NEG-OPEN","s1",{}]"#).await; + assert_eq!( + client.expect_frame().await, + r#"["NOTICE","invalid: Invalid input"]"# + ); + + client.send(r#"["REQ","s1"]"#).await; + assert_eq!( + client.expect_frame().await, + r#"["NOTICE","invalid: REQ message must contain at least one filter"]"# + ); + + client.send("{\"kind\":").await; + let notice = client.expect_frame().await; + assert!(notice.starts_with(r#"["NOTICE","invalid: "#), "{notice}"); +} + +#[tokio::test] +async fn a_close_emits_nothing_for_an_unknown_subscription() { + let server = start(Fixture::new(settings())).await; + let (mut client, _challenge) = server.connect().await; + + client.send(r#"["CLOSE","nothing"]"#).await; + + let frame = client.next_message().await; + assert!(frame.is_none(), "{frame:?}"); +} diff --git a/crates/nostream-relay/tests/hidden_content.rs b/crates/nostream-relay/tests/hidden_content.rs new file mode 100644 index 000000000..42cfecde0 --- /dev/null +++ b/crates/nostream-relay/tests/hidden_content.rs @@ -0,0 +1,219 @@ +//! The NIP-56 live-hiding mirror. Carries `test/unit/utils/hidden-content-cache.spec.ts` +//! (12 cases). + +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::float_cmp +)] + +mod common; + +use std::time::Duration; + +use common::{CLIENT_SECRET, NOW_MS, NOW_SECS, signed_event}; +use nostream_config::settings::{Nip56Settings, Settings}; +use nostream_core::clock::TestClock; +use nostream_relay::hidden_content::HiddenContentCache; +use nostream_relay::testing::{Fixture, nip56_settings, settings, settings_with_ewma}; +use nostream_storage::repositories::ActionableTarget; + +fn target_for_event(event: &nostream_core::Event) -> ActionableTarget { + ActionableTarget { + reported_pubkey: None, + reported_event_id: Some(event.id), + } +} + +fn target_for_pubkey(pubkey: nostream_core::Pubkey) -> ActionableTarget { + ActionableTarget { + reported_pubkey: Some(pubkey), + reported_event_id: None, + } +} + +fn hiding_on() -> Settings { + let mut settings = settings(); + settings.nip56 = Some(Nip56Settings { + enabled: true, + hide_actionable_reports: true, + ..nip56_settings() + }); + + settings +} + +#[test] +fn an_empty_mirror_hides_nothing() { + let cache = HiddenContentCache::new(); + let event = signed_event(&CLIENT_SECRET, 1, vec![], "", NOW_SECS); + + assert!(!cache.is_hidden(&event)); + assert!(cache.snapshot().event_ids.is_empty()); + assert!(cache.snapshot().pubkeys.is_empty()); +} + +#[test] +fn a_mark_hides_the_target_immediately() { + let cache = HiddenContentCache::new(); + let event = signed_event(&CLIENT_SECRET, 1, vec![], "", NOW_SECS); + + cache.mark_actionable_target(&target_for_event(&event)); + + assert!(cache.is_hidden(&event)); +} + +#[test] +fn a_mark_on_an_author_hides_every_event_of_that_author() { + let cache = HiddenContentCache::new(); + let first = signed_event(&CLIENT_SECRET, 1, vec![], "one", NOW_SECS); + let second = signed_event(&CLIENT_SECRET, 1, vec![], "two", NOW_SECS); + + cache.mark_actionable_target(&target_for_pubkey(first.pubkey)); + + assert!(cache.is_hidden(&first)); + assert!(cache.is_hidden(&second)); +} + +#[test] +fn a_snapshot_replaces_the_mirror_wholesale() { + let cache = HiddenContentCache::new(); + let hidden = signed_event(&CLIENT_SECRET, 1, vec![], "hidden", NOW_SECS); + let other = signed_event(&CLIENT_SECRET, 1, vec![], "other", NOW_SECS); + + cache.mark_actionable_target(&target_for_event(&hidden)); + assert!(cache.is_hidden(&hidden)); + + // A snapshot that names no target unhides everything the mark added. + cache.replace(&[]); + assert!(!cache.is_hidden(&hidden)); + + cache.replace(&[target_for_event(&other)]); + assert!(!cache.is_hidden(&hidden)); + assert!(cache.is_hidden(&other)); + + // One target may name both sides at once. + cache.replace(&[ActionableTarget { + reported_pubkey: Some(other.pubkey), + reported_event_id: Some(other.id), + }]); + assert!(cache.is_hidden(&other)); + + // A target that names neither hides nothing. + cache.replace(&[ActionableTarget { + reported_pubkey: None, + reported_event_id: None, + }]); + assert!(!cache.is_hidden(&other)); +} + +#[tokio::test] +async fn a_tick_with_hiding_enabled_queries_the_repository_and_counts() { + let fixture = Fixture::new(hiding_on()); + let event = signed_event(&CLIENT_SECRET, 1, vec![], "", NOW_SECS); + + fixture.reports.set_targets(vec![target_for_event(&event)]); + + let cache = HiddenContentCache::new(); + + assert_eq!(fixture.reports.queries(), 0); + + cache + .refresh(fixture.reports.as_ref(), &fixture.settings) + .await + .expect("a snapshot"); + + assert_eq!(fixture.reports.queries(), 1); + assert_eq!(cache.refreshes(), 1); + assert!(cache.is_hidden(&event)); +} + +#[tokio::test] +async fn a_tick_with_hiding_disabled_queries_nothing() { + for nip56 in [ + None, + Some(Nip56Settings { + enabled: false, + hide_actionable_reports: true, + ..nip56_settings() + }), + Some(Nip56Settings { + enabled: true, + hide_actionable_reports: false, + ..nip56_settings() + }), + ] { + let mut settings = settings_with_ewma(); + settings.nip56.clone_from(&nip56); + + let fixture = Fixture::new(settings); + let event = signed_event(&CLIENT_SECRET, 1, vec![], "", NOW_SECS); + + fixture.reports.set_targets(vec![target_for_event(&event)]); + + let cache = HiddenContentCache::new(); + + cache + .refresh(fixture.reports.as_ref(), &fixture.settings) + .await + .expect("a skipped tick"); + + assert_eq!(fixture.reports.queries(), 0, "no query: {nip56:?}"); + assert_eq!(cache.refreshes(), 0, "no refresh counted: {nip56:?}"); + assert!(!cache.is_hidden(&event)); + } +} + +#[tokio::test] +async fn a_failing_repository_leaves_the_mirror_as_it_was() { + let fixture = Fixture::new(hiding_on()); + let hidden = signed_event(&CLIENT_SECRET, 1, vec![], "kept", NOW_SECS); + + fixture.reports.fail_queries(); + fixture.reports.set_targets(Vec::new()); + + let cache = HiddenContentCache::new(); + cache.mark_actionable_target(&target_for_event(&hidden)); + + assert!( + cache + .refresh(fixture.reports.as_ref(), &fixture.settings) + .await + .is_err() + ); + assert!(cache.is_hidden(&hidden), "a failed tick changes nothing"); + assert_eq!(cache.refreshes(), 0); +} + +#[tokio::test] +async fn start_seeds_the_mirror_and_keeps_refreshing_until_dropped() { + let fixture = Fixture::new(hiding_on()); + let event = signed_event(&CLIENT_SECRET, 1, vec![], "", NOW_SECS); + + fixture.reports.set_targets(vec![target_for_event(&event)]); + + let cache = HiddenContentCache::new(); + let task = cache.start( + fixture.reports.clone(), + std::sync::Arc::clone(&fixture.settings), + Duration::from_millis(10), + std::sync::Arc::new(TestClock::new(NOW_MS)), + ); + + let mut seen = false; + + for _ in 0..200 { + if cache.is_hidden(&event) { + seen = true; + break; + } + + tokio::time::sleep(Duration::from_millis(5)).await; + } + + task.abort(); + + assert!(seen, "the first tick seeds the mirror"); + assert!(cache.refreshes() >= 1); +} diff --git a/crates/nostream-relay/tests/limits.rs b/crates/nostream-relay/tests/limits.rs new file mode 100644 index 000000000..ece3e2f06 --- /dev/null +++ b/crates/nostream-relay/tests/limits.rs @@ -0,0 +1,754 @@ +//! The rate limiters: the window math, its asymmetries, the four wiring levels, whitelists +//! and the fail-closed paths. Carries `test/unit/utils/sliding-window-rate-limiter.spec.ts` +//! (3 cases), `test/unit/utils/ewma-rate-limiter.spec.ts` (6), and the behaviour of +//! `src/handlers/request-handlers/rate-limiter-middleware.ts`. + +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::float_cmp +)] + +mod common; + +use std::sync::Arc; + +use common::{CLIENT_SECRET, NOW_MS, NOW_SECS, relay_keys, signed_event}; +use nostream_config::settings::{ + ConnectionLimits, EventLimits, EventRateLimit, InviteLimits, Limits, MessageLimits, + MessageRateLimit, RateLimit, RateLimiterStrategy, Settings, +}; +use nostream_core::clock::{Clock, TestClock}; +use nostream_core::{Event, Kind, KindSelector, Pubkey}; +use nostream_relay::limits::{ + EwmaRateLimiter, Limiter, RateLimitConfig, RateLimits, SlidingWindowRateLimiter, backoff_ms, + kind_selector_matches, +}; +use nostream_relay::testing::{FailingLimiter, Fixture, settings}; + +/// A sliding-window limiter over a clock the test moves. +fn sliding(start_ms: u64) -> (Arc, SlidingWindowRateLimiter) { + let clock = Arc::new(TestClock::new(start_ms)); + + ( + Arc::clone(&clock), + SlidingWindowRateLimiter::new(Arc::clone(&clock) as Arc), + ) +} + +fn ewma(start_ms: u64) -> (Arc, EwmaRateLimiter) { + let clock = Arc::new(TestClock::new(start_ms)); + + ( + Arc::clone(&clock), + EwmaRateLimiter::new(Arc::clone(&clock) as Arc), + ) +} + +/// A realistic start instant: with a clock at zero, a hit's score equals `now - period` +/// and the script's inclusive removal would drop it, which never happens with wall time. +const BASE: u64 = NOW_MS; + +const WINDOW: RateLimitConfig = RateLimitConfig { + period_ms: 60_000, + rate: 3, +}; + +/// `hit`, unwrapped: the in-process store cannot fail in these tests. +fn hit(limiter: &impl Limiter, key: &str, step: u64, config: &RateLimitConfig) -> bool { + limiter + .hit(key, step, config) + .expect("the in-process store") +} + +// --------------------------------------------------------------------------------------- +// Sliding window (R21) +// --------------------------------------------------------------------------------------- + +#[test] +fn the_sliding_window_allows_up_to_the_rate_and_refuses_the_next() { + let (_clock, limiter) = sliding(BASE); + + for _ in 0..3 { + assert!(!hit(&limiter, "k", 1, &WINDOW)); + } + + assert!(hit(&limiter, "k", 1, &WINDOW)); +} + +#[test] +fn sum_plus_step_equal_to_the_rate_is_allowed() { + let (_clock, limiter) = sliding(BASE); + + // step 3 in one hit against a rate of 3: 0 + 3 == 3 is allowed. + assert!(!hit(&limiter, "k", 3, &WINDOW)); + assert!(hit(&limiter, "k", 1, &WINDOW)); +} + +#[test] +fn a_refused_sliding_hit_is_not_recorded() { + let (_clock, limiter) = sliding(BASE); + let config = RateLimitConfig { + period_ms: 60_000, + rate: 1, + }; + + assert!(!hit(&limiter, "k", 1, &config)); + assert!(hit(&limiter, "k", 1, &config)); + assert_eq!(limiter.len("k"), 1, "the refused hit left no entry"); +} + +#[test] +fn the_sliding_window_boundary_is_inclusive_on_removal() { + let config = RateLimitConfig { + period_ms: 60_000, + rate: 1, + }; + + let (clock, limiter) = sliding(BASE); + assert!(!hit(&limiter, "k", 1, &config)); + + // Exactly one period later the entry's score equals `now - period` and is dropped. + clock.set(BASE + 60_000); + assert_eq!(limiter.len("k"), 1, "the entry has not been swept yet"); + assert!(!hit(&limiter, "k", 1, &config)); + + // One millisecond earlier the first entry still counts. + let (clock, limiter) = sliding(BASE); + assert!(!hit(&limiter, "k", 1, &config)); + clock.set(BASE + 59_999); + assert!(hit(&limiter, "k", 1, &config)); + + // Two periods later the last entry is outside the window again. + clock.set(BASE + 120_000); + assert!(!hit(&limiter, "k", 1, &config)); +} + +#[test] +fn keys_are_independent() { + let (_clock, limiter) = sliding(BASE); + let config = RateLimitConfig { + period_ms: 60_000, + rate: 1, + }; + + assert!(!hit(&limiter, "a", 1, &config)); + assert!(!hit(&limiter, "b", 1, &config)); + assert!(hit(&limiter, "a", 1, &config)); +} + +// --------------------------------------------------------------------------------------- +// EWMA (R21) +// --------------------------------------------------------------------------------------- + +#[test] +fn the_ewma_decays_by_half_each_period() { + // period 1500 ms: the entry's TTL is 2000 ms, so a hit exactly one half-life later + // still finds the stored rate (a whole-second period would have expired it). + let (clock, limiter) = ewma(BASE); + let config = RateLimitConfig { + period_ms: 1_500, + rate: 100, + }; + + assert!(!hit(&limiter, "k", 1, &config)); + let first = limiter.rate("k", &config).expect("a stored rate"); + assert!((first - 1.0).abs() < 1e-9, "{first}"); + + // One half-life later an old rate of 1 decays to 0.5, plus the new step. + clock.advance(1_500); + assert!(!hit(&limiter, "k", 1, &config)); + let second = limiter.rate("k", &config).expect("a stored rate"); + assert!((second - 1.5).abs() < 1e-9, "{second}"); +} + +#[test] +fn a_limited_ewma_hit_is_still_recorded() { + let (clock, limiter) = ewma(BASE); + let config = RateLimitConfig { + period_ms: 1_500, + rate: 1, + }; + + // 1.0 is not > 1, so the first hit is allowed. + assert!(!hit(&limiter, "k", 1, &config)); + // 1.0 + 1 = 2.0 > 1: limited, and stored. + assert!(hit(&limiter, "k", 1, &config)); + assert!( + limiter + .rate("k", &config) + .is_some_and(|rate| (rate - 2.0).abs() < 1e-9) + ); + + // The stored rate halves over a half-life; the next hit adds 1 and is limited again. + clock.advance(1_500); + assert!(hit(&limiter, "k", 1, &config)); +} + +#[test] +fn an_ewma_key_expires_after_its_ceil_ttl() { + // period 1500 ms -> ceil(1500 / 1000) * 1000 = 2000 ms TTL. + let (clock, limiter) = ewma(BASE); + let config = RateLimitConfig { + period_ms: 1_500, + rate: 1, + }; + + assert!(!hit(&limiter, "k", 1, &config)); + assert!(hit(&limiter, "k", 1, &config)); + + // At the TTL the entry is gone: the next hit starts from zero. + clock.set(BASE + 2_000); + assert_eq!(limiter.rate("k", &config), None); + + assert!(!hit(&limiter, "k", 1, &config)); + let rate = limiter.rate("k", &config).expect("a stored rate"); + assert!((rate - 1.0).abs() < 1e-9, "{rate}"); +} + +#[test] +fn a_fresh_ewma_key_starts_from_zero_at_now() { + let (clock, limiter) = ewma(1_000_000); + let config = RateLimitConfig { + period_ms: 60_000, + rate: 5, + }; + + assert!(!hit(&limiter, "fresh", 2, &config)); + let rate = limiter.rate("fresh", &config).expect("a stored rate"); + assert!((rate - 2.0).abs() < 1e-9, "{rate}"); + assert_eq!(clock.now_ms(), 1_000_000); +} + +// --------------------------------------------------------------------------------------- +// AC30: the model-based property test +// --------------------------------------------------------------------------------------- + +/// A tiny deterministic generator, so the property test needs no new dependency and its +/// failures are reproducible from the sequence number. +struct Generator { + state: u64, +} + +impl Generator { + fn new(seed: u64) -> Self { + Self { + state: seed ^ 0x9e37_79b9_7f4a_7c15, + } + } + + fn next(&mut self) -> u64 { + // SplitMix64. + self.state = self.state.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut z = self.state; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + + z ^ (z >> 31) + } + + fn below(&mut self, bound: u64) -> u64 { + if bound == 0 { 0 } else { self.next() % bound } + } +} + +/// The sliding-window script, re-expressed independently: one sorted set per key, entries +/// with their score and step, `ZREMRANGEBYSCORE 0 windowStart`, sum, then record only when +/// allowed. +#[derive(Default)] +struct SlidingModel { + entries: std::collections::BTreeMap>, +} + +impl SlidingModel { + fn hit(&mut self, key: &str, now: u64, step: u64, config: &RateLimitConfig) -> bool { + let window_start = now.saturating_sub(config.period_ms); + let entries = self.entries.entry(key.to_owned()).or_default(); + + entries.retain(|(at, _)| *at > window_start); + + let sum: u64 = entries.iter().map(|(_, step)| *step).sum(); + + if sum.saturating_add(step) > config.rate { + return true; + } + + entries.push((now, step)); + + false + } +} + +/// The EWMA script, re-expressed independently: decay, store, then compare. +struct EwmaModel { + state: Option<(f64, u64)>, +} + +impl EwmaModel { + fn hit(&mut self, now: u64, step: u64, config: &RateLimitConfig) -> bool { + let ttl = config.period_ms.div_ceil(1000) * 1000; + + let (old_rate, old_at) = match self.state { + Some((rate, at)) if now.saturating_sub(at) < ttl => (rate, at), + _ => (0.0, now), + }; + + let lambda = std::f64::consts::LN_2 / config.period_ms as f64; + let rate = old_rate * (-lambda * (now.saturating_sub(old_at)) as f64).exp() + step as f64; + + self.state = Some((rate, now)); + + rate > config.rate as f64 + } +} + +fn config_from(generator: &mut Generator) -> RateLimitConfig { + RateLimitConfig { + period_ms: [1_000, 1_500, 10_000, 60_000][generator.below(4) as usize], + rate: 1 + generator.below(5), + } +} + +#[test] +fn ten_thousand_sliding_sequences_agree_with_the_model() { + let mut disagreements = 0; + + for sequence in 0..10_000u64 { + let mut generator = Generator::new(sequence); + let clock = Arc::new(TestClock::new(1_000_000)); + let limiter = SlidingWindowRateLimiter::new(Arc::clone(&clock) as Arc); + let mut model = SlidingModel::default(); + let mut config = config_from(&mut generator); + + for _ in 0..8 { + let key = ["a", "b"][generator.below(2) as usize]; + let step = 1 + generator.below(3); + clock.advance(generator.below(config.period_ms * 2)); + + let expected = model.hit(key, clock.now_ms(), step, &config); + let actual = hit(&limiter, key, step, &config); + + if expected != actual { + disagreements += 1; + } + + if generator.below(4) == 0 { + config = config_from(&mut generator); + } + } + } + + assert_eq!(disagreements, 0); +} + +#[test] +fn ten_thousand_ewma_sequences_agree_with_the_model() { + let mut disagreements = 0; + + for sequence in 0..10_000u64 { + let mut generator = Generator::new(sequence ^ 0x5eed); + let clock = Arc::new(TestClock::new(1_000_000)); + let limiter = EwmaRateLimiter::new(Arc::clone(&clock) as Arc); + let mut model = EwmaModel { state: None }; + let config = config_from(&mut generator); + + for _ in 0..8 { + let step = 1 + generator.below(2); + clock.advance(generator.below(config.period_ms)); + + let expected = model.hit(clock.now_ms(), step, &config); + let actual = hit(&limiter, "k", step, &config); + + if expected != actual { + disagreements += 1; + } + } + } + + assert_eq!(disagreements, 0); +} + +// --------------------------------------------------------------------------------------- +// Wiring, whitelists and selection (R20/R22) +// --------------------------------------------------------------------------------------- + +fn limits_from(settings: &Settings, clock: Arc) -> RateLimits { + RateLimits::new(settings, clock) +} + +fn settings_with(limits: Limits) -> Settings { + let mut settings = settings(); + settings.limits = Some(limits); + + settings +} + +#[test] +fn the_strategy_defaults_to_ewma_and_honours_sliding_window() { + let clock = Arc::new(TestClock::new(BASE)); + let defaults = settings(); + + assert_eq!( + limits_from(&defaults, Arc::clone(&clock)).strategy(), + RateLimiterStrategy::Ewma + ); + + let sliding_settings = settings_with(Limits { + rate_limiter: Some(nostream_config::settings::RateLimiterSettings { + strategy: RateLimiterStrategy::SlidingWindow, + }), + ..Default::default() + }); + + assert_eq!( + limits_from(&sliding_settings, clock).strategy(), + RateLimiterStrategy::SlidingWindow + ); +} + +#[test] +fn the_connection_level_uses_addr_connection_period_and_its_whitelist() { + let clock = Arc::new(TestClock::new(BASE)); + let settings = settings_with(Limits { + connection: Some(ConnectionLimits { + rate_limits: vec![RateLimit { + description: None, + period: 1_000, + rate: 1, + }], + ip_whitelist: Some(vec!["10.0.0.1".to_owned()]), + ip_blacklist: None, + }), + ..Default::default() + }); + let limits = limits_from(&settings, clock); + + assert!(!limits.connection_limited("192.0.2.1").expect("store")); + assert!(limits.connection_limited("192.0.2.1").expect("store")); + assert!(!limits.connection_limited("10.0.0.1").expect("store")); +} + +#[test] +fn the_message_level_reports_the_period_as_the_backoff() { + let clock = Arc::new(TestClock::new(BASE)); + let settings = settings_with(Limits { + message: Some(MessageLimits { + rate_limits: Some(vec![MessageRateLimit { + description: None, + message_type: Some("REQ".to_owned()), + period: 2_500, + rate: 1, + }]), + ip_whitelist: None, + }), + ..Default::default() + }); + let limits = limits_from(&settings, clock); + + assert_eq!(limits.message_backoff("192.0.2.1").expect("store"), None); + assert_eq!( + limits.message_backoff("192.0.2.1").expect("store"), + Some(2_500) + ); + assert_eq!( + backoff_ms(&RateLimitConfig { + period_ms: 2_500, + rate: 1 + }), + 2_500 + ); + assert_eq!( + backoff_ms(&RateLimitConfig { + period_ms: 0, + rate: 1 + }), + 1, + "Math.max(1, ceil(period))" + ); +} + +#[test] +fn the_message_level_with_no_windows_never_limits() { + let clock = Arc::new(TestClock::new(BASE)); + let settings = settings_with(Limits { + message: Some(MessageLimits { + rate_limits: Some(Vec::new()), + ip_whitelist: None, + }), + ..Default::default() + }); + + assert_eq!( + limits_from(&settings, clock) + .message_backoff("192.0.2.1") + .expect("store"), + None + ); +} + +fn event_at(kind: u64, pubkey: Pubkey) -> Event { + use nostream_core::event::{EventId, Signature}; + + Event { + id: EventId::from_byte_array([1; 32]), + pubkey, + created_at: NOW_SECS, + kind: Kind(kind), + tags: Vec::new(), + content: String::new(), + sig: Signature::from_byte_array([0; 64]), + } +} + +#[test] +fn the_event_level_keys_by_pubkey_period_and_kinds_and_skips_other_kinds() { + let clock = Arc::new(TestClock::new(BASE)); + let pubkey = nostream_core::crypto::public_key(&CLIENT_SECRET).expect("pubkey"); + let settings = settings_with(Limits { + event: Some(EventLimits { + rate_limits: Some(vec![EventRateLimit { + description: None, + kinds: Some(vec![KindSelector::Range([Kind(1), Kind(2)])]), + period: 60_000, + rate: 1, + }]), + ..Default::default() + }), + ..Default::default() + }); + let limits = limits_from(&settings, clock); + + let other = event_at(3, pubkey); + assert_eq!( + limits + .event_backoff(&other, "192.0.2.1", None) + .expect("store"), + None + ); + + let matching = event_at(1, pubkey); + assert_eq!( + limits + .event_backoff(&matching, "192.0.2.1", None) + .expect("store"), + None + ); + assert_eq!( + limits + .event_backoff(&matching, "192.0.2.1", None) + .expect("store"), + Some(60_000) + ); + assert!(kind_selector_matches( + KindSelector::Single(Kind(4)), + Kind(4) + )); + assert!(!kind_selector_matches( + KindSelector::Single(Kind(4)), + Kind(5) + )); +} + +#[test] +fn the_event_level_bypasses_the_relay_pubkey_and_both_whitelists() { + let clock = Arc::new(TestClock::new(BASE)); + let pubkey = nostream_core::crypto::public_key(&CLIENT_SECRET).expect("pubkey"); + let settings = settings_with(Limits { + event: Some(EventLimits { + rate_limits: Some(vec![EventRateLimit { + description: None, + kinds: None, + period: 60_000, + rate: 1, + }]), + whitelists: Some(nostream_config::settings::EventWhitelists { + pubkeys: Some(vec![nostream_core::SettingsPubkey(pubkey.to_string())]), + ip_addresses: Some(vec!["10.0.0.1".to_owned()]), + }), + ..Default::default() + }), + ..Default::default() + }); + let limits = limits_from(&settings, Arc::clone(&clock)); + let event = event_at(1, pubkey); + + // The pubkey and the address whitelist each bypass. + assert_eq!( + limits + .event_backoff(&event, "192.0.2.1", None) + .expect("store"), + None + ); + assert_eq!( + limits + .event_backoff(&event, "10.0.0.1", None) + .expect("store"), + None + ); + + let plain = settings_with(Limits { + event: Some(EventLimits { + rate_limits: Some(vec![EventRateLimit { + description: None, + kinds: None, + period: 60_000, + rate: 1, + }]), + ..Default::default() + }), + ..Default::default() + }); + let limits = limits_from(&plain, Arc::new(TestClock::new(BASE))); + + // The relay's own key bypasses even without a whitelist. + assert_eq!( + limits + .event_backoff(&event, "192.0.2.1", Some(&pubkey)) + .expect("store"), + None + ); + assert_eq!( + limits + .event_backoff(&event, "192.0.2.1", None) + .expect("store"), + None + ); + assert_eq!( + limits + .event_backoff(&event, "192.0.2.1", None) + .expect("store"), + Some(60_000) + ); +} + +#[test] +fn the_invite_level_is_scoped_per_pubkey() { + let clock = Arc::new(TestClock::new(BASE)); + let pubkey = nostream_core::crypto::public_key(&CLIENT_SECRET).expect("pubkey"); + let other = nostream_core::crypto::public_key(&[9u8; 32]).expect("pubkey"); + let settings = settings_with(Limits { + invite: Some(InviteLimits { + rate_limits: Some(vec![RateLimit { + description: None, + period: 60_000, + rate: 1, + }]), + }), + ..Default::default() + }); + let limits = limits_from(&settings, clock); + + assert!(!limits.invite_limited(&pubkey).expect("store")); + assert!(limits.invite_limited(&pubkey).expect("store")); + assert!(!limits.invite_limited(&other).expect("store")); +} + +#[test] +fn a_failing_store_reports_an_error_at_every_level() { + let settings = settings_with(Limits { + connection: Some(ConnectionLimits { + rate_limits: vec![RateLimit { + description: None, + period: 1_000, + rate: 1, + }], + ip_whitelist: None, + ip_blacklist: None, + }), + message: Some(MessageLimits { + rate_limits: Some(vec![MessageRateLimit { + description: None, + message_type: None, + period: 1_000, + rate: 1, + }]), + ip_whitelist: None, + }), + event: Some(EventLimits { + rate_limits: Some(vec![EventRateLimit { + description: None, + kinds: None, + period: 1_000, + rate: 1, + }]), + ..Default::default() + }), + invite: Some(InviteLimits { + rate_limits: Some(vec![RateLimit { + description: None, + period: 1_000, + rate: 1, + }]), + }), + ..Default::default() + }); + let limits = RateLimits::with_limiter(Arc::new(FailingLimiter), &settings); + let pubkey = nostream_core::crypto::public_key(&CLIENT_SECRET).expect("pubkey"); + + assert!(limits.connection_limited("192.0.2.1").is_err()); + assert!(limits.message_backoff("192.0.2.1").is_err()); + assert!( + limits + .event_backoff(&event_at(1, pubkey), "192.0.2.1", None) + .is_err() + ); + assert!(limits.invite_limited(&pubkey).is_err()); +} + +#[tokio::test] +async fn a_failing_event_limiter_limits_the_event_without_a_backoff() { + let fixture = Fixture::new(settings_with(Limits { + event: Some(EventLimits { + rate_limits: Some(vec![EventRateLimit { + description: None, + kinds: None, + period: 60_000, + rate: 1, + }]), + ..Default::default() + }), + ..Default::default() + })); + + let (sink, mut frames) = nostream_relay::sink::FrameSink::channel(); + let clock = Arc::new(TestClock::new(NOW_MS)); + let connection = Arc::new(nostream_relay::connection::Connection::new( + "127.0.0.1".to_owned(), + nostream_core::nip42::Nip42Session::with_challenge("challenge", 0), + )); + let limits = Arc::new(RateLimits::with_limiter( + Arc::new(FailingLimiter), + &fixture.settings, + )); + let context = fixture.context_with_limits(clock, relay_keys(), connection, sink, limits); + + let event = signed_event(&CLIENT_SECRET, 1, vec![], "", NOW_SECS); + nostream_relay::handlers::event::handle(event.clone(), &context).await; + + let frame = frames.recv().await.expect("a frame"); + + assert_eq!( + frame, + format!(r#"["OK","{}",false,"rate-limited: slow down"]"#, event.id), + "fail closed, with no backoff to promise" + ); +} + +#[test] +fn the_limiter_trait_exposes_the_selected_store() { + let clock = Arc::new(TestClock::new(BASE)); + let defaults = settings(); + let limiter = nostream_relay::limits::RateLimiter::from_settings(&defaults, clock); + + assert_eq!(limiter.strategy(), RateLimiterStrategy::Ewma); + assert!(!hit( + &limiter, + "k", + 1, + &RateLimitConfig { + period_ms: 1, + rate: 1 + } + )); +} diff --git a/crates/nostream-relay/tests/transport.rs b/crates/nostream-relay/tests/transport.rs new file mode 100644 index 000000000..486b532a4 --- /dev/null +++ b/crates/nostream-relay/tests/transport.rs @@ -0,0 +1,573 @@ +//! The websocket transport and the per-socket loop: the payload limit, the close codes, a +//! declined compression offer, liveness, client pings and the drain. +//! +//! Carries `test/unit/adapters/web-socket-server-adapter.spec.ts` (17 cases) and the framing +//! half of `web-socket-adapter.spec.ts` (60). The variants A3 and A8 call for need a raw +//! socket, because a websocket library will not make a client break the protocol on purpose: +//! RSV bits, an extension offer, and reading a ping without answering it. + +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::float_cmp +)] + +mod common; + +use std::time::Duration; + +use common::{Client, Server, fast_config, liveness_config, relay_keys}; +use futures_util::SinkExt; +use nostream_relay::testing::{Fixture, settings}; +use nostream_relay::transport::{self, DEFAULT_MAX_PAYLOAD}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; + +// --------------------------------------------------------------------------------------- +// A raw websocket client: the handshake by hand, frames by hand. +// --------------------------------------------------------------------------------------- + +/// One frame read off the wire. +#[derive(Debug, PartialEq, Eq)] +struct Frame { + fin: bool, + rsv1: bool, + opcode: u8, + payload: Vec, +} + +impl Frame { + fn text(&self) -> String { + String::from_utf8(self.payload.clone()).expect("utf8 payload") + } +} + +struct RawClient { + stream: TcpStream, + /// Everything read past the last complete frame, so a read never loses bytes. + buffered: Vec, +} + +impl RawClient { + /// Connects and completes the handshake, returning the response's headers. + async fn connect(address: std::net::SocketAddr, extensions: Option<&str>) -> (Self, String) { + let mut stream = TcpStream::connect(address).await.expect("connect"); + + let mut request = format!( + "GET / HTTP/1.1\r\nHost: {address}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\ + Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n" + ); + + if let Some(extensions) = extensions { + request.push_str(&format!("Sec-WebSocket-Extensions: {extensions}\r\n")); + } + + request.push_str("\r\n"); + + stream + .write_all(request.as_bytes()) + .await + .expect("write handshake"); + + let mut client = Self { + stream, + buffered: Vec::new(), + }; + let head = client.read_until_headers_end().await; + + (client, head) + } + + async fn read_until_headers_end(&mut self) -> String { + loop { + if let Some(end) = find(&self.buffered, b"\r\n\r\n") { + let head = String::from_utf8(self.buffered[..end].to_vec()).expect("utf8 head"); + self.buffered.drain(..end + 4); + + return head; + } + + if !self.fill().await { + panic!("the handshake response ended early"); + } + } + } + + /// Appends whatever the socket has; `false` at end of stream. + async fn fill(&mut self) -> bool { + let mut buffer = [0u8; 4096]; + + match tokio::time::timeout(Duration::from_secs(5), self.stream.read(&mut buffer)).await { + Ok(Ok(0)) | Ok(Err(_)) | Err(_) => false, + Ok(Ok(read)) => { + self.buffered.extend_from_slice(&buffer[..read]); + + true + } + } + } + + /// Sends a masked client frame. A text frame is masked, as the protocol requires. + async fn send_frame(&mut self, fin: bool, rsv1: bool, opcode: u8, payload: &[u8]) { + let mut frame = Vec::with_capacity(payload.len() + 14); + let first = (u8::from(fin) << 7) | (u8::from(rsv1) << 6) | opcode; + + frame.push(first); + + let mask = [0x12u8, 0x34, 0x56, 0x78]; + + if payload.len() < 126 { + frame.push(0x80 | payload.len() as u8); + } else if payload.len() <= u16::MAX as usize { + frame.push(0x80 | 126); + frame.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + } else { + frame.push(0x80 | 127); + frame.extend_from_slice(&(payload.len() as u64).to_be_bytes()); + } + + frame.extend_from_slice(&mask); + + for (index, byte) in payload.iter().enumerate() { + frame.push(byte ^ mask[index % 4]); + } + + self.stream.write_all(&frame).await.expect("write frame"); + } + + async fn send_text(&mut self, text: &str) { + self.send_frame(true, false, 0x1, text.as_bytes()).await; + } + + /// The next complete server frame, or `None` at end of stream. + async fn next_frame(&mut self) -> Option { + loop { + if let Some(frame) = self.parse_frame() { + return Some(frame); + } + + if !self.fill().await { + return None; + } + } + } + + fn parse_frame(&mut self) -> Option { + let header = self.buffered.first().copied()?; + + if self.buffered.len() < 2 { + return None; + } + + let second = self.buffered[1]; + let masked = second & 0x80 != 0; + let mut length = u64::from(second & 0x7f); + let mut offset = 2; + + if length == 126 { + let bytes = self.buffered.get(2..4)?; + length = u64::from(u16::from_be_bytes([bytes[0], bytes[1]])); + offset = 4; + } else if length == 127 { + let bytes = self.buffered.get(2..10)?; + length = u64::from_be_bytes([ + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + ]); + offset = 10; + } + + let length = usize::try_from(length).expect("a length that fits"); + + if masked { + offset += 4; + } + + let payload = self.buffered.get(offset..offset + length)?.to_vec(); + + self.buffered.drain(..offset + length); + + Some(Frame { + fin: header & 0x80 != 0, + rsv1: header & 0x40 != 0, + opcode: header & 0x0f, + payload, + }) + } + + /// Every frame until one arrives whose opcode matches, or the stream ends. + async fn frames_until(&mut self, opcode: u8) -> Vec { + let mut frames = Vec::new(); + + while let Some(frame) = self.next_frame().await { + let matched = frame.opcode == opcode; + + frames.push(frame); + + if matched { + break; + } + } + + frames + } + + /// Reads until the stream ends, returning every frame that arrived. + async fn frames_until_eof(&mut self) -> Vec { + let mut frames = Vec::new(); + + while let Some(frame) = self.next_frame().await { + frames.push(frame); + } + + frames + } +} + +fn find(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +/// The close code and reason of a close frame. +fn close_of(frame: &Frame) -> (u16, String) { + let code = u16::from_be_bytes([frame.payload[0], frame.payload[1]]); + + ( + code, + String::from_utf8(frame.payload[2..].to_vec()).expect("utf8 reason"), + ) +} + +fn settings_with_payload(size: u64) -> nostream_config::settings::Settings { + let mut settings = settings(); + settings.network.max_payload_size = Some(size); + + settings +} + +// --------------------------------------------------------------------------------------- +// The payload limit (R27) +// --------------------------------------------------------------------------------------- + +#[tokio::test] +async fn the_payload_limit_defaults_to_131072_and_follows_the_settings() { + assert_eq!( + transport::max_payload_size(&settings()), + DEFAULT_MAX_PAYLOAD + ); + assert_eq!(transport::max_payload_size(&settings()), 131_072); + assert_eq!( + transport::max_payload_size(&settings_with_payload(524_288)), + 524_288 + ); + // Zero and absent both fall back: `?? 131072`, then `if (!payload) 131072`. + assert_eq!( + transport::max_payload_size(&settings_with_payload(0)), + DEFAULT_MAX_PAYLOAD + ); +} + +#[tokio::test] +async fn an_oversized_message_is_closed_with_1009() { + let server = Server::start(Fixture::new(settings())).await; + let (mut client, _challenge) = server.connect().await; + + // Above the 131072-byte default, in one frame. + let oversized = "x".repeat(140_000); + client.send(&oversized).await; + + let closed = client + .next_message() + .await + .expect("a close frame, not a dropped socket"); + + match closed { + Message::Close(Some(frame)) => { + assert_eq!(frame.code, CloseCode::Size); + assert_eq!(frame.reason.as_str(), ""); + } + other => panic!("expected a 1009 close, got {other:?}"), + } +} + +#[tokio::test] +async fn the_configured_limit_is_the_one_enforced() { + // 1024 bytes: a text frame of 2000 bytes is refused. + let server = Server::start(Fixture::new(settings_with_payload(1024))).await; + let (mut client, _challenge) = server.connect().await; + + client.send(&"x".repeat(2_000)).await; + + match client.next_message().await.expect("a close frame") { + Message::Close(Some(frame)) => assert_eq!(frame.code, CloseCode::Size), + other => panic!("expected a 1009 close, got {other:?}"), + } + + // The same frame under the default limit is decoded and rejected, not dropped. + let server = Server::start(Fixture::new(settings())).await; + let (mut client, _challenge) = server.connect().await; + + client.send(&"x".repeat(2_000)).await; + + assert!( + client + .expect_frame() + .await + .starts_with(r#"["NOTICE","invalid: "#) + ); +} + +// --------------------------------------------------------------------------------------- +// Protocol violations (R27) +// --------------------------------------------------------------------------------------- + +#[tokio::test] +async fn a_rsv_bit_with_no_negotiated_extension_is_closed_with_1002() { + let server = Server::start(Fixture::new(settings())).await; + + // A second connection, opened before the violation, must survive it. + let (mut survivor, challenge) = server.connect().await; + assert!(!challenge.is_empty()); + + let (mut raw, head) = RawClient::connect(server.address, None).await; + assert!(head.starts_with("HTTP/1.1 101"), "{head}"); + + // Consume the AUTH challenge, then break the protocol: RSV1 set with no extension. + let auth = raw.next_frame().await.expect("the AUTH frame"); + assert_eq!(auth.opcode, 0x1); + + raw.send_frame(true, true, 0x1, br#"["REQ","s1",{}]"#).await; + + let frames = raw.frames_until(0x8).await; + + assert_eq!(frames.last().map(close_of), Some((1002, String::new()))); + + // The surviving connection still answers. + survivor.send(r#"["REQ","alive",{"kinds":[1]}]"#).await; + assert_eq!(survivor.expect_frame().await, r#"["EOSE","alive"]"#); +} + +#[tokio::test] +async fn a_fragmented_text_message_is_reassembled_before_decoding() { + let server = Server::start(Fixture::new(settings())).await; + let (mut raw, _head) = RawClient::connect(server.address, None).await; + + let _auth = raw.next_frame().await.expect("the AUTH frame"); + + raw.send_frame(false, false, 0x1, br#"["REQ","frag","#) + .await; + raw.send_frame(true, false, 0x0, br#"{"kinds":[1]}]"#).await; + + let frames = raw.frames_until(0x1).await; + let eose = frames.last().expect("a text frame"); + + assert_eq!(eose.text(), r#"["EOSE","frag"]"#); +} + +// --------------------------------------------------------------------------------------- +// Compression (R27) +// --------------------------------------------------------------------------------------- + +#[tokio::test] +async fn a_declined_compression_offer_leaves_the_session_uncompressed() { + let server = Server::start(Fixture::new(settings())).await; + + let (mut raw, head) = RawClient::connect( + server.address, + Some("permessage-deflate; client_max_window_bits"), + ) + .await; + + assert!(head.starts_with("HTTP/1.1 101"), "{head}"); + assert!( + !head + .to_ascii_lowercase() + .contains("sec-websocket-extensions"), + "the offer must be declined: {head}" + ); + + // The AUTH challenge arrives as a plain text frame: RSV1 clear, payload is JSON. + let auth = raw.next_frame().await.expect("the AUTH frame"); + + assert!(!auth.rsv1, "no extension was negotiated"); + assert_eq!(auth.opcode, 0x1); + assert!(auth.text().starts_with(r#"["AUTH","#), "{auth:?}"); + + // And a request is answered uncompressed too. + raw.send_text(r#"["REQ","plain",{"kinds":[1]}]"#).await; + + let eose = raw.frames_until(0x1).await; + + assert!(eose.iter().all(|frame| !frame.rsv1)); + assert_eq!( + eose.last().expect("a text frame").text(), + r#"["EOSE","plain"]"# + ); +} + +// --------------------------------------------------------------------------------------- +// Liveness (R26) +// --------------------------------------------------------------------------------------- + +#[tokio::test] +async fn a_client_that_never_pongs_is_closed_without_a_close_frame() { + let interval = Duration::from_millis(20); + let server = Server::start_with( + Fixture::new(settings()), + relay_keys(), + liveness_config(interval), + ) + .await; + + let (mut raw, _head) = RawClient::connect(server.address, None).await; + + // Read every frame — including the pings — and never answer one. + let frames = tokio::time::timeout(Duration::from_millis(500), raw.frames_until_eof()) + .await + .expect("the relay closes the socket within its own interval"); + + let pings = frames.iter().filter(|frame| frame.opcode == 0x9).count(); + + assert!(pings >= 1, "the relay pinged before giving up: {frames:?}"); + assert!( + frames.iter().all(|frame| frame.opcode != 0x8), + "a liveness failure closes with no code: {frames:?}" + ); +} + +#[tokio::test] +async fn a_client_that_answers_every_ping_survives() { + let interval = Duration::from_millis(20); + let server = Server::start_with( + Fixture::new(settings()), + relay_keys(), + liveness_config(interval), + ) + .await; + + let (mut client, _challenge) = server.connect().await; + let mut pings = 0; + + // Each read answers the server's ping at the protocol layer, which is what keeps the + // connection alive; ten intervals must pass without a close. + for _ in 0..10 { + match client.next_message().await { + Some(Message::Ping(_)) => pings += 1, + Some(Message::Close(frame)) => panic!("closed early: {frame:?}"), + Some(Message::Text(text)) => assert!(text.contains("AUTH")), + other => panic!("unexpected frame: {other:?}"), + } + } + + assert_eq!(pings, 10); + + client.send(r#"["REQ","still-here",{"kinds":[1]}]"#).await; + assert_eq!(client.expect_frame().await, r#"["EOSE","still-here"]"#); +} + +#[tokio::test] +async fn a_client_ping_is_answered_with_the_same_payload() { + let server = Server::start(Fixture::new(settings())).await; + let (mut client, _challenge) = server.connect().await; + + client + .stream() + .send(Message::Ping(b"hello".to_vec().into())) + .await + .expect("send a ping"); + + let answer = client.next_message().await.expect("a pong"); + + assert_eq!(answer, Message::Pong(b"hello".to_vec().into())); +} + +// --------------------------------------------------------------------------------------- +// Drain (R26) +// --------------------------------------------------------------------------------------- + +#[tokio::test] +async fn draining_closes_every_subscription_then_the_socket_with_1001() { + let server = Server::start(Fixture::new(settings())).await; + let (mut client, _challenge) = server.connect().await; + + client.send(r#"["REQ","s1",{"kinds":[1]}]"#).await; + assert_eq!(client.expect_frame().await, r#"["EOSE","s1"]"#); + client.send(r#"["REQ","s2",{"kinds":[1]}]"#).await; + assert_eq!(client.expect_frame().await, r#"["EOSE","s2"]"#); + + server.begin_draining(); + + assert_eq!( + client.expect_frame().await, + r#"["CLOSED","s1","error: relay shutting down"]"# + ); + assert_eq!( + client.expect_frame().await, + r#"["CLOSED","s2","error: relay shutting down"]"# + ); + + match client.next_message().await.expect("a close frame") { + Message::Close(Some(frame)) => { + assert_eq!(frame.code, CloseCode::Away); + assert_eq!(frame.reason.as_str(), transport::DRAIN_REASON); + } + other => panic!("expected a 1001 close, got {other:?}"), + } +} + +#[tokio::test] +async fn a_connection_accepted_while_draining_is_closed_with_1001() { + let server = Server::start(Fixture::new(settings())).await; + + // Drive the socket path directly: the serve loop stops listening the moment the flag + // is set, so the accept-and-refuse branch is reached through `handle_socket`. + let shutdown = server.shutdown(); + shutdown.begin_draining(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let address = listener.local_addr().expect("local address"); + let relay = std::sync::Arc::clone(&server.relay); + + let accepting = tokio::spawn(async move { + let (stream, peer) = listener.accept().await.expect("accept"); + + relay.handle_socket(stream, peer, shutdown).await; + }); + + let mut client = Client::connect(address).await; + let _ = accepting.await; + + match client.next_message().await.expect("a close frame") { + Message::Close(Some(frame)) => { + assert_eq!(frame.code, CloseCode::Away); + assert_eq!(frame.reason.as_str(), transport::DRAIN_REASON); + } + other => panic!("expected a 1001 close, got {other:?}"), + } +} + +#[tokio::test] +async fn a_connection_with_no_subscriptions_is_closed_without_a_closed_frame() { + let config = nostream_relay::relay::RelayConfig { + drain_timeout: Duration::from_millis(50), + ..fast_config() + }; + let server = Server::start_with(Fixture::new(settings()), relay_keys(), config).await; + + let (mut client, _challenge) = server.connect().await; + server.begin_draining(); + + match client.next_message().await.expect("a close frame") { + Message::Close(Some(frame)) => { + assert_eq!(frame.code, CloseCode::Away); + assert_eq!(frame.reason.as_str(), transport::DRAIN_REASON); + } + other => panic!("expected only the 1001 close, got {other:?}"), + } +} From 4a9ebfe8199dcb496aab60ee003212efde457802 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Fri, 23 Oct 2026 14:55:30 -0400 Subject: [PATCH 03/20] feat(nostreamd): serve the relay through the acceptor --- crates/nostreamd/Cargo.toml | 4 + crates/nostreamd/src/acceptor.rs | 50 ++++++------- crates/nostreamd/src/lib.rs | 5 +- crates/nostreamd/src/main.rs | 24 +++++- crates/nostreamd/tests/acceptor.rs | 21 +++++- crates/nostreamd/tests/wire.rs | 114 +++++++++++++++++++++++++++++ 6 files changed, 180 insertions(+), 38 deletions(-) create mode 100644 crates/nostreamd/tests/wire.rs diff --git a/crates/nostreamd/Cargo.toml b/crates/nostreamd/Cargo.toml index 5060a4077..62cf4558a 100644 --- a/crates/nostreamd/Cargo.toml +++ b/crates/nostreamd/Cargo.toml @@ -33,7 +33,11 @@ default = [] otlp = ["dep:opentelemetry", "dep:opentelemetry-otlp", "dep:tracing-opentelemetry"] [dev-dependencies] +futures-util.workspace = true +nostream-relay.workspace = true +serde_json.workspace = true tempfile.workspace = true +tokio-tungstenite.workspace = true [lints] workspace = true diff --git a/crates/nostreamd/src/acceptor.rs b/crates/nostreamd/src/acceptor.rs index 01b7b66f5..841e81487 100644 --- a/crates/nostreamd/src/acceptor.rs +++ b/crates/nostreamd/src/acceptor.rs @@ -1,14 +1,20 @@ -//! The acceptor: one task owning the listener, accepting sockets, handing each to the relay. +//! The acceptor: the bound listener and the one task that serves it. //! //! This is the Phase-1 runtime decision (ADR 0001): a **single acceptor task**, no //! `SO_REUSEPORT`, no per-core acceptor sharding. The capture's model is a cluster primary //! forking `cpus().length` client workers that all `listen()` the same port plus an IPC hop; //! Phase 2 decides against or reproduces that fan-out on measurements. +//! +//! The accepted socket is handed to [`nostream_relay::Relay`], which upgrades it, runs its +//! per-socket state machine and drains it on shutdown — the acceptor no longer drops it. use std::net::SocketAddr; +use std::sync::Arc; use tokio::net::TcpListener; -use tokio::sync::watch; + +use nostream_core::lifecycle::ShutdownState; +use nostream_relay::{Relay, RelayError}; use crate::error::DaemonError; @@ -39,32 +45,22 @@ impl Acceptor { self.local_addr } - /// Accepts sockets until `shutdown` fires; every socket is dropped with a debug line in - /// Phase 1 — the relay wire takes the socket in Phase 2. - pub async fn run(&self, mut shutdown: watch::Receiver) -> Result<(), DaemonError> { - loop { - let shutting_down = *shutdown.borrow(); - if shutting_down { - tracing::debug!(at = %self.local_addr, "drained; the acceptor stops accepting"); - return Ok(()); - } + /// Serves the listener through `relay` until `shutdown` fires, then drains. + /// + /// `Relay::serve` owns the accept loop so the socket never leaves the relay's hands: it + /// upgrades each accepted stream, spawns its connection task and waits out the drain + /// timeout (`web-socket-server-adapter.ts:67-95`). + pub async fn run(self, relay: Arc, shutdown: ShutdownState) -> Result<(), DaemonError> { + tracing::debug!(at = %self.local_addr, "the relay takes the listener"); - let (socket, peer) = tokio::select! { - accepted = self.listener.accept() => match accepted { - Ok(pair) => pair, - Err(error) => { - tracing::warn!(error = %error, "accept failed"); - continue; - } - }, - _ = shutdown.changed() => { - tracing::debug!(at = %self.local_addr, "drained; the acceptor stops accepting"); - return Ok(()); + relay + .serve(self.listener, shutdown) + .await + .map_err(|error| match error { + RelayError::Draining => { + DaemonError::Setup("the relay was already draining".to_owned()) } - }; - - drop(socket); - tracing::debug!(%peer, "accepted a socket; the relay wire is Phase 2, dropping it"); - } + other => DaemonError::Setup(other.to_string()), + }) } } diff --git a/crates/nostreamd/src/lib.rs b/crates/nostreamd/src/lib.rs index 0578c9b69..9e2f0c01b 100644 --- a/crates/nostreamd/src/lib.rs +++ b/crates/nostreamd/src/lib.rs @@ -20,9 +20,8 @@ //! `SO_REUSEPORT` sharding — the capture's cluster fan-out (a primary forking //! `cpus().length` client workers that each `listen()` the same port, IPC into each //! worker's own adapter; the primary itself never listens) is the model Phase 2 decides -//! against. Phase 1 binds, accepts, drains on SIGINT/SIGTERM through -//! `nostream_core::lifecycle::ShutdownState` and drops sockets with a debug line; the relay -//! wire takes the socket in Phase 2. +//! against. The daemon binds, hands the listener to `nostream_relay::Relay`, and drains on +//! SIGINT/SIGTERM through `nostream_core::lifecycle::ShutdownState`. //! //! The MCP server named in the issue is Phase 5 and has no TS counterpart at the capture; it //! has no module here either. diff --git a/crates/nostreamd/src/main.rs b/crates/nostreamd/src/main.rs index 7cd3ba6e3..d283b8e2b 100644 --- a/crates/nostreamd/src/main.rs +++ b/crates/nostreamd/src/main.rs @@ -9,7 +9,9 @@ use std::process::ExitCode; use clap::Parser; use nostream_config::SettingsPaths; +use nostream_core::clock::SystemClock; use nostream_core::lifecycle::ShutdownState; +use nostream_relay::{Relay, relay_key::RelayKeySource, unavailable}; use nostreamd::{DaemonError, settings_gate, telemetry}; #[derive(Debug, Parser)] @@ -92,17 +94,31 @@ fn run(cli: Cli) -> Result { .build() .map_err(|error| DaemonError::Setup(format!("tokio runtime: {error}")))?; - runtime.block_on(async move { serve(listen).await }) + let settings = outcome.settings; + + runtime.block_on(async move { serve(listen, settings).await }) } -async fn serve(listen: SocketAddr) -> Result { +async fn serve( + listen: SocketAddr, + settings: nostream_config::Settings, +) -> Result { let acceptor = nostreamd::Acceptor::bind(listen).await?; let shutdown = ShutdownState::new(); - let watch_rx = shutdown.watch(); + + // The storage backend is not wired yet (the LMDB environment is a stub), so the relay + // runs over seams that report `NotImplemented`; the wire itself is served in full. + let relay = Relay::with_keys( + settings, + std::sync::Arc::new(unavailable::stores()), + std::sync::Arc::new(SystemClock), + RelayKeySource::from_env(), + ); tracing::info!(at = %acceptor.local_addr(), "the acceptor is listening"); - let accept_task = tokio::spawn(async move { acceptor.run(watch_rx).await }); + let connection_shutdown = shutdown.clone(); + let accept_task = tokio::spawn(async move { acceptor.run(relay, connection_shutdown).await }); let workers_task = tokio::spawn(async { nostreamd::workers::spawn_workers().await }); let tor_task = tokio::spawn(async { nostreamd::workers::spawn_tor().await }); diff --git a/crates/nostreamd/tests/acceptor.rs b/crates/nostreamd/tests/acceptor.rs index af9a9a5d3..0822b6fc3 100644 --- a/crates/nostreamd/tests/acceptor.rs +++ b/crates/nostreamd/tests/acceptor.rs @@ -5,9 +5,14 @@ // workspace-wide, so a new test file still has to ask. #![allow(clippy::unwrap_used, clippy::expect_used)] +use std::sync::Arc; use std::time::Duration; +use nostream_core::clock::TestClock; use nostream_core::lifecycle::ShutdownState; +use nostream_relay::Relay; +use nostream_relay::relay_key::RelayKeySource; +use nostream_relay::testing::{Fixture, settings}; use nostreamd::Acceptor; #[tokio::test] @@ -18,12 +23,19 @@ async fn the_acceptor_binds_an_ephemeral_port_and_drains_on_shutdown() { let port = acceptor.local_addr().port(); assert!(port > 0, "the ephemeral port resolved"); - let shutdown = ShutdownState::new(); - let watch_rx = shutdown.watch(); + let fixture = Fixture::new(settings()); + let relay = Relay::with_keys( + (*fixture.settings).clone(), + Arc::clone(&fixture.stores), + Arc::new(TestClock::new(1_700_000_000_000)), + RelayKeySource::from_values(None, None), + ); - let task = tokio::spawn(async move { acceptor.run(watch_rx).await }); + let shutdown = ShutdownState::new(); + let connection_shutdown = shutdown.clone(); + let task = tokio::spawn(async move { acceptor.run(relay, connection_shutdown).await }); - // A socket connects and is dropped (Phase 1 accepts and drops; the wire is Phase 2). + // A socket connects and the relay takes it; the wire answers with the NIP-42 challenge. let stream = tokio::net::TcpStream::connect(("127.0.0.1", port)) .await .expect("connect"); @@ -32,6 +44,7 @@ async fn the_acceptor_binds_an_ephemeral_port_and_drains_on_shutdown() { tokio::time::sleep(Duration::from_millis(50)).await; shutdown.begin_draining(); + let result = tokio::time::timeout(Duration::from_secs(2), task) .await .expect("the acceptor stops within two seconds") diff --git a/crates/nostreamd/tests/wire.rs b/crates/nostreamd/tests/wire.rs new file mode 100644 index 000000000..98dd57e26 --- /dev/null +++ b/crates/nostreamd/tests/wire.rs @@ -0,0 +1,114 @@ +//! The daemon serves the wire: AC2 (`R1`) — the acceptor hands the socket to the relay and +//! the first frame a client receives is the NIP-42 challenge. +//! +//! | case | Rust test | +//! |---|---| +//! | a client connects and receives `["AUTH","<43 base64url chars>"]` | `the_first_frame_is_the_nip42_challenge` | +//! | a second connection gets its own challenge | `every_connection_gets_its_own_challenge` | + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::Arc; +use std::time::Duration; + +use futures_util::StreamExt; +use nostream_core::clock::TestClock; +use nostream_core::lifecycle::ShutdownState; +use nostream_relay::Relay; +use nostream_relay::relay_key::RelayKeySource; +use nostream_relay::testing::{Fixture, settings}; +use nostreamd::Acceptor; +use tokio_tungstenite::tungstenite::Message; + +async fn start() -> (u16, ShutdownState) { + let acceptor = Acceptor::bind("127.0.0.1:0".parse().expect("socket addr")) + .await + .expect("bind"); + let port = acceptor.local_addr().port(); + + let fixture = Fixture::new(settings()); + let relay = Relay::with_keys( + (*fixture.settings).clone(), + Arc::clone(&fixture.stores), + Arc::new(TestClock::new(1_700_000_000_000)), + RelayKeySource::from_values(None, None), + ); + + let shutdown = ShutdownState::new(); + let connection_shutdown = shutdown.clone(); + + tokio::spawn(async move { + let _ = acceptor.run(relay, connection_shutdown).await; + }); + + (port, shutdown) +} + +async fn first_frame( + port: u16, +) -> ( + tokio_tungstenite::WebSocketStream>, + String, +) { + let (mut client, _response) = tokio::time::timeout( + Duration::from_secs(2), + tokio_tungstenite::connect_async(format!("ws://127.0.0.1:{port}/")), + ) + .await + .expect("connect within two seconds") + .expect("handshake"); + + let message = tokio::time::timeout(Duration::from_secs(2), client.next()) + .await + .expect("a frame within two seconds") + .expect("a frame") + .expect("a text frame"); + + let Message::Text(text) = message else { + panic!("expected a text frame, got {message:?}"); + }; + + (client, text.as_str().to_owned()) +} + +#[tokio::test] +async fn the_first_frame_is_the_nip42_challenge() { + let (port, shutdown) = start().await; + let (client, text) = first_frame(port).await; + + let frame: serde_json::Value = serde_json::from_str(&text).expect("a JSON frame"); + let array = frame.as_array().expect("a positional array"); + + assert_eq!(array.first().and_then(|verb| verb.as_str()), Some("AUTH")); + + let challenge = array + .get(1) + .and_then(|challenge| challenge.as_str()) + .expect("a challenge"); + + // 32 bytes base64url without padding is 43 characters, and only the URL-safe alphabet. + assert_eq!(challenge.len(), 43, "{challenge}"); + assert!( + challenge + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_'), + "{challenge}" + ); + + drop(client); + shutdown.begin_draining(); +} + +#[tokio::test] +async fn every_connection_gets_its_own_challenge() { + let (port, shutdown) = start().await; + + let (first_client, first) = first_frame(port).await; + let (second_client, second) = first_frame(port).await; + + assert_ne!(first, second, "the challenge is per socket"); + + drop(first_client); + drop(second_client); + shutdown.begin_draining(); +} From 7785e06238a56b6383affdb56a4430146205ebdf Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Fri, 23 Oct 2026 14:59:24 -0400 Subject: [PATCH 04/20] refactor(relay): route every outbound frame through the transport --- crates/nostream-relay/src/relay.rs | 53 ++++++++++----------- crates/nostream-relay/src/strategies/mod.rs | 7 ++- crates/nostream-relay/src/transport.rs | 43 +++++++++++++++++ 3 files changed, 73 insertions(+), 30 deletions(-) diff --git a/crates/nostream-relay/src/relay.rs b/crates/nostream-relay/src/relay.rs index 5062f2de7..ea7668d9d 100644 --- a/crates/nostream-relay/src/relay.rs +++ b/crates/nostream-relay/src/relay.rs @@ -300,12 +300,10 @@ impl Relay { let mut hub = self.hub.subscribe(); let mut drained = shutdown.watch(); - // `tokio::time::interval`'s first tick is immediate; the capture's `setInterval` - // first fires one interval after the socket opens. - let mut liveness = tokio::time::interval_at( - tokio::time::Instant::now() + self.config.liveness_interval, - self.config.liveness_interval, - ); + // `tokio::time::interval`'s first tick is immediate and the capture's `setInterval` + // first fires one interval after the socket opens, so the interval is re-armed. + let mut liveness = tokio::time::interval(self.config.liveness_interval); + liveness.reset(); let mut alive = true; let mut handlers: JoinSet<()> = JoinSet::new(); @@ -317,7 +315,7 @@ impl Relay { frame = frames.recv() => { let Some(text) = frame else { break }; - if transport.send(Message::Text(text.into())).await.is_err() { + if transport::send_text(&mut transport, &text).await.is_err() { break; } } @@ -325,27 +323,25 @@ impl Relay { let Some(message) = message else { break }; match message { - Ok(Message::Text(text)) => { - alive = true; - self.on_client_message(text.as_str(), &context, &mut handlers); - } - Ok(Message::Binary(_)) => { - alive = true; - } - Ok(Message::Ping(payload)) => { - // A client Ping is answered in place and marks the socket alive - // (`web-socket-adapter.ts:342-346`). - alive = true; - - if transport.send(Message::Pong(payload)).await.is_err() { - break; + Ok(message) => match transport::classify(message) { + transport::Inbound::Text(text) => { + alive = true; + self.on_client_message(&text, &context, &mut handlers); } - } - Ok(Message::Pong(_)) => { - alive = true; - } - Ok(Message::Close(_)) => break, - Ok(Message::Frame(_)) => {} + transport::Inbound::Ping(payload) => { + // A client Ping is answered in place and marks the socket + // alive (`web-socket-adapter.ts:342-346`). + alive = true; + + if transport::send_pong(&mut transport, payload).await.is_err() { + break; + } + } + transport::Inbound::Binary | transport::Inbound::Pong => { + alive = true; + } + transport::Inbound::Close | transport::Inbound::Other => break, + }, Err(error) => { self.close_on_error(&mut transport, &error).await; break; @@ -478,8 +474,7 @@ impl Relay { params: None, }); - if transport - .send(Message::Text(frame.to_text().into())) + if transport::send_text(transport, &frame.to_text()) .await .is_err() { diff --git a/crates/nostream-relay/src/strategies/mod.rs b/crates/nostream-relay/src/strategies/mod.rs index cc16a2fdc..ada792c5d 100644 --- a/crates/nostream-relay/src/strategies/mod.rs +++ b/crates/nostream-relay/src/strategies/mod.rs @@ -266,7 +266,12 @@ mod stubs { Err(RelayError::NotImplemented { module: reported }) => { assert_eq!(reported, module, "{selection:?}"); } - other => panic!("{selection:?} must be a NotImplemented stub, got {other:?}"), + // Anything but `NotImplemented` means the arm was wired without a + // port: `assert` rather than `panic!`, and the message names the arm. + other => assert!( + matches!(other, Err(RelayError::NotImplemented { .. })), + "{selection:?} must be a NotImplemented stub, got {other:?}" + ), } } }); diff --git a/crates/nostream-relay/src/transport.rs b/crates/nostream-relay/src/transport.rs index 774e6f920..1ef803248 100644 --- a/crates/nostream-relay/src/transport.rs +++ b/crates/nostream-relay/src/transport.rs @@ -11,6 +11,9 @@ use std::time::Duration; +use futures_util::SinkExt; +use tokio_tungstenite::tungstenite::Message; + use nostream_config::settings::Settings; use tokio::net::TcpStream; use tokio_tungstenite::WebSocketStream; @@ -127,6 +130,46 @@ pub fn is_protocol_error(error: &tokio_tungstenite::tungstenite::Error) -> bool matches!(error, tokio_tungstenite::tungstenite::Error::Protocol(_)) } +/// One inbound websocket message, as the per-socket loop needs it. Classification lives here +/// so that the loop never names a `Message` variant of its own. +#[derive(Debug)] +pub enum Inbound { + Text(String), + Binary, + Ping(Vec), + Pong, + Close, + Other, +} + +/// Classifies an inbound message (`web-socket-adapter.ts:198-240`). +pub fn classify(message: Message) -> Inbound { + match message { + Message::Text(text) => Inbound::Text(text.as_str().to_owned()), + Message::Binary(_) => Inbound::Binary, + Message::Ping(payload) => Inbound::Ping(payload.to_vec()), + Message::Pong(_) => Inbound::Pong, + Message::Close(_) => Inbound::Close, + Message::Frame(_) => Inbound::Other, + } +} + +/// Writes one text frame. This is the relay's single write point for outbound JSON. +pub async fn send_text( + transport: &mut AcceptedTransport, + text: &str, +) -> Result<(), tokio_tungstenite::tungstenite::Error> { + transport.send(Message::Text(text.to_owned().into())).await +} + +/// Answers a client Ping in place. +pub async fn send_pong( + transport: &mut AcceptedTransport, + payload: Vec, +) -> Result<(), tokio_tungstenite::tungstenite::Error> { + transport.send(Message::Pong(payload.into())).await +} + /// A negotiated socket, for callers that hold the concrete type. pub type Transport = WebSocketStream; From 15c6525e55ebfbbae3f4d029e6e3af345fe7d04f Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Fri, 23 Oct 2026 16:03:56 -0400 Subject: [PATCH 05/20] test(relay): add the case census and the wire-session oracle --- CLAUDE.md | 2 +- crates/nostream-core/src/lib.rs | 17 +- crates/nostream-core/src/nip42.rs | 21 +- crates/nostream-core/src/nip43.rs | 10 +- crates/nostream-core/src/random.rs | 186 + crates/nostream-core/src/validation.rs | 11 +- crates/nostream-relay/src/connection.rs | 4 +- crates/nostream-relay/src/handlers/mod.rs | 3 + .../nostream-relay/src/handlers/subscribe.rs | 2 +- crates/nostream-relay/src/relay.rs | 19 + crates/nostream-relay/src/testing.rs | 1 + .../tests/capture/gen-ts-cases.mjs | 123 + .../tests/capture/gen-wire-sessions.mjs | 1024 +++ .../tests/fixtures/ts-case-map.json | 1212 +++ .../tests/fixtures/ts-cases.json | 696 ++ .../tests/fixtures/wire-sessions.json | 7480 +++++++++++++++++ crates/nostream-relay/tests/handlers.rs | 19 + crates/nostream-relay/tests/transport.rs | 13 + crates/nostream-relay/tests/ts_case_parity.rs | 279 + crates/nostream-relay/tests/wire_parity.rs | 908 ++ docs/rewrite/decisions/0004-wire-transport.md | 133 + 21 files changed, 12137 insertions(+), 26 deletions(-) create mode 100644 crates/nostream-core/src/random.rs create mode 100644 crates/nostream-relay/tests/capture/gen-ts-cases.mjs create mode 100644 crates/nostream-relay/tests/capture/gen-wire-sessions.mjs create mode 100644 crates/nostream-relay/tests/fixtures/ts-case-map.json create mode 100644 crates/nostream-relay/tests/fixtures/ts-cases.json create mode 100644 crates/nostream-relay/tests/fixtures/wire-sessions.json create mode 100644 crates/nostream-relay/tests/ts_case_parity.rs create mode 100644 crates/nostream-relay/tests/wire_parity.rs create mode 100644 docs/rewrite/decisions/0004-wire-transport.md diff --git a/CLAUDE.md b/CLAUDE.md index b57f8b323..b786dc485 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,7 @@ resolver 3: | `nostream-core` | domain vocabulary: events, filters, kinds, wire messages, NIP type surfaces, `ShutdownState` | | `nostream-config` | typed settings, loader, deep merge, validator, dot-path editor, redaction, guided catalogue | | `nostream-storage` | event-store backends (LMDB via heed, Postgres), cache | -| `nostream-relay` | acceptor, connections, wire protocol, NIP-77 engine | +| `nostream-relay` | connections, wire protocol, handlers, rate limiters, live dispatch, NIP-77 engine (the acceptor lives in `nostreamd`) | | `nostream-services` | NIP-66 monitors, Tor, schedulers, outbound work | | `nostream-admin` | admin API and auth | | `nostream-cli` | operator command line (`nostream-cli config validate`) | diff --git a/crates/nostream-core/src/lib.rs b/crates/nostream-core/src/lib.rs index f39dfbd5c..bd39c990a 100644 --- a/crates/nostream-core/src/lib.rs +++ b/crates/nostream-core/src/lib.rs @@ -26,13 +26,19 @@ //! Implemented here, against those files: the 32-byte `EventId`/`Pubkey` and 64-byte //! `Signature` with the schema's hex rules in the type, the canonical serialization and //! SHA-256 event id, BIP-340 Schnorr verification and signing, the NIP-01 filter matcher -//! and the zod filter schema's rules, subscription normalisation, NIP-13 difficulty, -//! NIP-40 expiration, NIP-44 v2, and the NIP-77 reconciliation engine (over the -//! `negentropy` crate; see `negentropy.rs` for the seam and its divergences). +//! and the zod filter schema's rules with `count_filter_values`/`is_generic_tag_query`, +//! subscription normalisation, NIP-13 difficulty, NIP-40 expiration, NIP-44 v2, the strict +//! event schema ([`validation::validate_event`] with the kind 7/17/10002 and geohash +//! refinements), the wire codec ([`messages::decode`] and the one encoder +//! `RelayMessage::to_text`), NIP-42 ([`nip42::authenticate`], [`nip42::generate_challenge`] +//! and [`nip42::Nip42Session`]), the NIP-43 invite code and its event, the NIP-05 identifier +//! helpers, the adaptive-difficulty signal and its WoT policy, [`clock::Clock`] and +//! [`random::RandomSource`], and the NIP-77 reconciliation engine (over the `negentropy` +//! crate; see `negentropy.rs` for the seam and its divergences). //! //! What is still a stub is marked as such on the item and returns -//! `CoreError::NotImplemented`: the NIP frames, the event acceptance gate's validator, and -//! the adaptive-difficulty policy. +//! `CoreError::NotImplemented`: the NIP frames' own codec layer, NIP-05 verification +//! (`nip05::verify`), and the NIP-77 session state machine. //! //! Phase 2+ areas this crate deliberately does not own: the NIP-77 wire state machine and //! its `NEG-OPEN`/`NEG-MSG`/`NEG-CLOSE` frame encoder belong to `nostream-relay` once a real @@ -70,6 +76,7 @@ pub mod nip66; pub mod nip77; pub mod nip98; pub mod pow; +pub mod random; pub mod replaceable; pub mod report_scoring; pub mod secret; diff --git a/crates/nostream-core/src/nip42.rs b/crates/nostream-core/src/nip42.rs index e70e9d661..83a2efd23 100644 --- a/crates/nostream-core/src/nip42.rs +++ b/crates/nostream-core/src/nip42.rs @@ -126,12 +126,13 @@ pub fn is_relay_url_match(client_relay: &str, server_relay: &str) -> bool { /// Generates a fresh challenge for a connection: 32 OS-random bytes, base64url without /// padding (`Nip42SessionManager.createChallenge`, `src/utils/nip42-session.ts:29-31`). pub fn generate_challenge() -> Result { - let mut bytes = [0u8; 32]; - - getrandom::fill(&mut bytes) - .map_err(|error| CoreError::InvalidMessage(format!("challenge entropy: {error}")))?; + challenge_from(&crate::random::SystemRandom) +} - Ok(URL_SAFE_NO_PAD.encode(bytes)) +/// The same challenge over an injected source, which is how a recorded fixture replays a +/// session byte-for-byte. +pub fn challenge_from(random: &dyn crate::random::RandomSource) -> Result { + Ok(URL_SAFE_NO_PAD.encode(random.bytes(32)?)) } /// The per-connection NIP-42 state (`Nip42SessionManager`, `src/utils/nip42-session.ts`). @@ -151,8 +152,16 @@ impl Nip42Session { /// Mints the connection's challenge. `ttl_seconds` is `nip42.sessionExpirySeconds`, /// where `0` means no expiry. pub fn new(ttl_seconds: i64) -> Result { + Self::from_random(ttl_seconds, &crate::random::SystemRandom) + } + + /// The session for a challenge drawn from `random`. + pub fn from_random( + ttl_seconds: i64, + random: &dyn crate::random::RandomSource, + ) -> Result { Ok(Self { - challenge: generate_challenge()?, + challenge: challenge_from(random)?, ttl_seconds, sessions: BTreeMap::new(), accepted_auth_event_ids: HashSet::new(), diff --git a/crates/nostream-core/src/nip43.rs b/crates/nostream-core/src/nip43.rs index af1f0d250..cc551ece1 100644 --- a/crates/nostream-core/src/nip43.rs +++ b/crates/nostream-core/src/nip43.rs @@ -63,12 +63,12 @@ pub fn resolve_invite_limits( /// A fresh invite code: 16 random bytes, hex (`generateInviteCode`, /// `src/utils/nip43-invites.ts:14`). pub fn generate_invite_code() -> Result { - let mut bytes = [0u8; 16]; - - getrandom::fill(&mut bytes) - .map_err(|error| CoreError::InvalidMessage(format!("invite code entropy: {error}")))?; + invite_code_from(&crate::random::SystemRandom) +} - Ok(hex::encode(bytes)) +/// The same code over an injected source, for the differential fixture. +pub fn invite_code_from(random: &dyn crate::random::RandomSource) -> Result { + Ok(hex::encode(random.bytes(16)?)) } /// Builds the relay-signed kind-28935 event that answers an invite request diff --git a/crates/nostream-core/src/random.rs b/crates/nostream-core/src/random.rs new file mode 100644 index 000000000..721d93710 --- /dev/null +++ b/crates/nostream-core/src/random.rs @@ -0,0 +1,186 @@ +//! The server's randomness, behind a seam so a recorded fixture can replay it. +//! +//! Two values in the wire protocol are server-minted at random: the NIP-42 challenge (32 bytes) +//! and a NIP-43 invite code (16 bytes). A differential fixture has to compare frames +//! byte-for-byte, including the signatures over those values, so the recording and the replay +//! must draw the same bytes (R29's "determinism seams are the fixture's, not the code's"). +//! +//! [`SystemRandom`] is what the daemon runs. [`SeededRandom`] is the stream +//! `crates/nostream-relay/tests/capture/gen-wire-sessions.mjs` also implements: SplitMix64 over +//! the fixture's declared seed, eight bytes per draw, little-endian. Both sides keep one +//! generator per socket, drawing the challenge first and the invite code later, so the order is +//! the fixture's to fix. + +use std::sync::Mutex; + +use crate::error::CoreError; + +/// A source of random bytes, one length at a time. +pub trait RandomSource: Send + Sync + std::fmt::Debug { + /// `len` bytes from the stream. + fn bytes(&self, len: usize) -> Result, CoreError>; +} + +/// `crypto.randomBytes`: the daemon's source. +#[derive(Debug, Default, Clone, Copy)] +pub struct SystemRandom; + +impl RandomSource for SystemRandom { + fn bytes(&self, len: usize) -> Result, CoreError> { + let mut buffer = vec![0u8; len]; + + getrandom::fill(&mut buffer) + .map_err(|error| CoreError::InvalidMessage(format!("entropy: {error}")))?; + + Ok(buffer) + } +} + +/// SplitMix64, the fixture's stream. Same arithmetic as the generator's `next()`. +#[derive(Debug)] +pub struct SeededRandom { + state: Mutex, +} + +impl SeededRandom { + /// The stream for `seed`, at its first draw. + pub fn new(seed: u64) -> Self { + Self { + state: Mutex::new(seed), + } + } + + fn next(&self) -> u64 { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + *state = state.wrapping_add(0x9e37_79b9_7f4a_7c15); + + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + + z ^ (z >> 31) + } +} + +impl RandomSource for SeededRandom { + fn bytes(&self, len: usize) -> Result, CoreError> { + let mut bytes = Vec::with_capacity(len); + + while bytes.len() < len { + bytes.extend_from_slice(&self.next().to_le_bytes()); + } + + bytes.truncate(len); + + Ok(bytes) + } +} + +/// The draws a recording used, replayed in order. The differential fixture records the +/// challenge and the invite code each session's relay minted, so the replay hands the relay +/// the same bytes rather than depending on a generator's position in a shared stream. +#[derive(Debug, Default)] +pub struct RecordedRandom { + draws: Mutex>>, +} + +impl RecordedRandom { + /// A source over `draws`, in order. + pub fn new(draws: Vec>) -> Self { + Self { + draws: Mutex::new(draws.into()), + } + } + + /// How many draws are left. + pub fn remaining(&self) -> usize { + self.draws + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .len() + } +} + +impl RandomSource for RecordedRandom { + fn bytes(&self, len: usize) -> Result, CoreError> { + let mut draws = self + .draws + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + match draws.pop_front() { + Some(draw) if draw.len() == len => Ok(draw), + Some(draw) => Err(CoreError::InvalidMessage(format!( + "recorded draw is {} bytes, {len} were requested", + draw.len() + ))), + None => Err(CoreError::InvalidMessage( + "the recording has no draw left".to_owned(), + )), + } + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use super::*; + + #[test] + fn the_stream_is_the_documented_splitmix64_sequence() { + let random = SeededRandom::new(0); + + // SplitMix64's first output from seed 0 is 0xe220_a839_7b1d_cdaf, drawn little-endian. + assert_eq!( + random.bytes(8).expect("bytes"), + vec![0xaf, 0xcd, 0x1d, 0x7b, 0x39, 0xa8, 0x20, 0xe2] + ); + // The second draw continues the stream rather than repeating it. + assert_ne!( + random.bytes(8).expect("bytes"), + random.bytes(8).expect("bytes") + ); + } + + #[test] + fn a_prefix_and_a_longer_draw_of_one_stream_agree() { + let short = SeededRandom::new(42).bytes(4).expect("bytes"); + let long = SeededRandom::new(42).bytes(20).expect("bytes"); + + assert_eq!(short, long[..4]); + } + + #[test] + fn the_recorded_source_replays_in_order_and_refuses_to_invent() { + let random = RecordedRandom::new(vec![vec![1, 2, 3, 4], vec![5, 6]]); + + assert_eq!(random.bytes(4).expect("a draw"), vec![1, 2, 3, 4]); + assert_eq!(random.bytes(2).expect("a draw"), vec![5, 6]); + assert_eq!(random.remaining(), 0); + assert!(random.bytes(4).is_err()); + assert!(random.bytes(2).is_err(), "and it stays exhausted"); + } + + #[test] + fn a_recorded_draw_of_the_wrong_length_is_an_error() { + let random = RecordedRandom::new(vec![vec![1, 2, 3]]); + + assert!(random.bytes(4).is_err()); + } + + #[test] + fn the_system_source_fills_and_differs() { + let random = SystemRandom; + + assert_eq!(random.bytes(16).expect("bytes").len(), 16); + assert_ne!( + random.bytes(16).expect("bytes"), + random.bytes(16).expect("bytes") + ); + } +} diff --git a/crates/nostream-core/src/validation.rs b/crates/nostream-core/src/validation.rs index 1b47b33d2..a1704e2a3 100644 --- a/crates/nostream-core/src/validation.rs +++ b/crates/nostream-core/src/validation.rs @@ -2,10 +2,8 @@ //! `src/schemas/event-schema.ts` and the id/signature half of `src/utils/event.ts` //! (`isEventIdValid`, `isEventSignatureValid`). //! -//! [`validate_event`] is the schema check the codec runs before a frame reaches a handler; -//! [`validate_event_fields`] is the same check at the value level, where it can report the -//! schema's own message for a malformed field (the NOTICE carries it). The two share -//! [`check_event_shape`], so the typed and untyped entry points cannot disagree. +//! [`validate_event`] is the schema check the codec runs before a frame reaches a handler. It +//! reports the schema's own message for a malformed field, which is what the NOTICE carries. use crate::error::CoreError; use crate::event::{Event, Kind, Tag}; @@ -30,9 +28,8 @@ const RELAY_LIST_KIND: Kind = Kind(10_002); /// Validates an event against the base schema (`eventSchema.parse`, `src/schemas/event-schema.ts`). /// -/// The hex fields' length and alphabet are carried by [`EventId`](crate::event::EventId), -/// [`Pubkey`] and [`Signature`], so what is left is `created_at`, the tag tuples, and the -/// kind-7/17/10002/geohash refinements. +/// The hex fields' length and alphabet are carried by `EventId`, `Pubkey` and `Signature`, so +/// what is left is `created_at`, the tag tuples, and the kind-7/17/10002/geohash refinements. pub fn validate_event(event: &Event) -> Result<(), CoreError> { check_event_shape(event).map_err(CoreError::InvalidEvent) } diff --git a/crates/nostream-relay/src/connection.rs b/crates/nostream-relay/src/connection.rs index ed37909f5..370e451d5 100644 --- a/crates/nostream-relay/src/connection.rs +++ b/crates/nostream-relay/src/connection.rs @@ -15,6 +15,7 @@ use tokio::sync::watch; use nostream_config::settings::Settings; use nostream_core::clock::Clock; use nostream_core::nip42::Nip42Session; +use nostream_core::random::RandomSource; use nostream_core::{Event, Filter, Pubkey}; use crate::error::RelayError; @@ -63,7 +64,7 @@ impl Connection { .and_then(|nipp42| nipp42.session_expiry_seconds) .unwrap_or(0); - let session = Nip42Session::new(ttl_seconds) + let session = Nip42Session::from_random(ttl_seconds, context.random.as_ref()) .map_err(|error| RelayError::Configuration(error.to_string()))?; context @@ -279,6 +280,7 @@ impl Connection { pub struct ConnectionContext { pub settings: Arc, pub clock: Arc, + pub random: Arc, pub sink: FrameSink, } diff --git a/crates/nostream-relay/src/handlers/mod.rs b/crates/nostream-relay/src/handlers/mod.rs index d2fe1c0fc..948c3659b 100644 --- a/crates/nostream-relay/src/handlers/mod.rs +++ b/crates/nostream-relay/src/handlers/mod.rs @@ -23,6 +23,7 @@ use nostream_core::adaptive_pow::AdaptivePowState; use nostream_core::clock::Clock; use nostream_core::messages::{ClosedMessage, CommandResult, NoticeMessage, RelayMessage}; use nostream_core::nip42; +use nostream_core::random::RandomSource; use nostream_core::{Event, KindSelector, Pubkey}; use nostream_storage::repositories::{ DvmJobRepository, EventRepository, InviteCodeRepository, Nip05VerificationRepository, @@ -54,6 +55,8 @@ pub struct Stores { pub struct HandlerContext { pub settings: Arc, pub clock: Arc, + /// The server's randomness: the challenge and an invite code (R29's fixture seam). + pub random: Arc, pub sink: FrameSink, pub connection: Arc, pub stores: Arc, diff --git a/crates/nostream-relay/src/handlers/subscribe.rs b/crates/nostream-relay/src/handlers/subscribe.rs index 71331856c..97e8f030f 100644 --- a/crates/nostream-relay/src/handlers/subscribe.rs +++ b/crates/nostream-relay/src/handlers/subscribe.rs @@ -176,7 +176,7 @@ async fn mint_invite_code( ) .map_err(|error| RelayError::Configuration(error.to_string()))?; - let code = nostream_core::nip43::generate_invite_code() + let code = nostream_core::nip43::invite_code_from(context.random.as_ref()) .map_err(|error| RelayError::Configuration(error.to_string()))?; let options = nostream_storage::repositories::CreateInviteCodeOptions { diff --git a/crates/nostream-relay/src/relay.rs b/crates/nostream-relay/src/relay.rs index ea7668d9d..954b93bd0 100644 --- a/crates/nostream-relay/src/relay.rs +++ b/crates/nostream-relay/src/relay.rs @@ -63,6 +63,7 @@ pub struct Relay { clock: Arc, keys: Arc, limits: Arc, + random: Arc, hidden: Arc, hub: broadcast::Sender, pow: Arc>, @@ -98,6 +99,7 @@ impl Relay { Arc::new(Self { limits: Arc::new(RateLimits::new(&settings, Arc::clone(&clock))), + random: Arc::new(nostream_core::random::SystemRandom), settings, stores, clock, @@ -125,6 +127,21 @@ impl Relay { &self.config } + /// Replaces the randomness source, returning the relay for chaining. A recorded fixture + /// replays its session with the recording's stream. + pub fn with_random( + mut self: Arc, + random: Arc, + ) -> Arc { + Arc::get_mut(&mut self) + .map(|relay| relay.random = random) + .unwrap_or_else(|| { + tracing::warn!("relay is already shared; the randomness was not replaced"); + }); + + self + } + /// The hidden-content mirror, so a caller can seed it. pub fn hidden_content(&self) -> &Arc { &self.hidden @@ -140,6 +157,7 @@ impl Relay { HandlerContext { settings: Arc::clone(&self.settings), clock: Arc::clone(&self.clock), + random: Arc::clone(&self.random), sink, connection, stores: Arc::clone(&self.stores), @@ -285,6 +303,7 @@ impl Relay { let connection_context = ConnectionContext { settings: Arc::clone(&self.settings), clock: Arc::clone(&self.clock), + random: Arc::clone(&self.random), sink: sink.clone(), }; diff --git a/crates/nostream-relay/src/testing.rs b/crates/nostream-relay/src/testing.rs index b8b9de4ec..94d170770 100644 --- a/crates/nostream-relay/src/testing.rs +++ b/crates/nostream-relay/src/testing.rs @@ -922,6 +922,7 @@ impl Fixture { HandlerContext { settings: Arc::clone(&self.settings), clock: Arc::clone(&clock), + random: Arc::new(nostream_core::random::SeededRandom::new(0)), sink, connection, stores: Arc::clone(&self.stores), diff --git a/crates/nostream-relay/tests/capture/gen-ts-cases.mjs b/crates/nostream-relay/tests/capture/gen-ts-cases.mjs new file mode 100644 index 000000000..9ebeb6177 --- /dev/null +++ b/crates/nostream-relay/tests/capture/gen-ts-cases.mjs @@ -0,0 +1,123 @@ +// Regenerates ../fixtures/ts-cases.json: the census of unit cases this job must carry. +// +// Subject: cameri/nostream at 242a3001d77149e4d70151ec03060c4ef44a1547 — the capture commit +// this branch is cut from. The harness MUST run with that tree as its working directory: it +// loads the spec files from /test and the modules they import from /src. +// +// One-time setup, in a scratch directory (never in the working tree): +// git archive 242a3001 | tar -x -C /tmp/nostream-capture +// ln -s /node_modules /tmp/nostream-capture/node_modules +// +// Run, from the capture tree: +// NODE_OPTIONS=--no-experimental-strip-types TS_NODE_TRANSPILE_ONLY=true \ +// node [--out-dir /crates/nostream-relay/tests/fixtures] +// +// The generator runs mocha with its JSON reporter over the 22 in-scope spec files, checks each +// file's case count against the table below (the spec's Context table, measured at the capture), +// and writes one row per file with every case's full title. `ts-case-map.json` maps those titles +// to the Rust tests that carry them; `tests/ts_case_parity.rs` holds the two files against each +// other. +import fs from 'node:fs' +import path from 'node:path' +import { execFileSync } from 'node:child_process' + +const CAPTURE = '242a3001d77149e4d70151ec03060c4ef44a1547' + +// The spec's Context table: the 22 in-scope files and their measured case counts. +const FILES = [ + ['test/unit/handlers/event-message-handler.spec.ts', 200], + ['test/unit/handlers/subscribe-message-handler.spec.ts', 58], + ['test/unit/handlers/count-message-handler.spec.ts', 10], + ['test/unit/handlers/auth-message-handler.spec.ts', 12], + ['test/unit/handlers/unsubscribe-message-handler.spec.ts', 1], + ['test/unit/handlers/event-strategies/default-event-strategy.spec.ts', 4], + ['test/unit/adapters/web-socket-adapter.spec.ts', 60], + ['test/unit/adapters/web-socket-server-adapter.spec.ts', 17], + ['test/unit/utils/nip42.spec.ts', 27], + ['test/unit/utils/nip42-session.spec.ts', 9], + ['test/unit/utils/messages.spec.ts', 14], + ['test/unit/utils/filter.spec.ts', 9], + ['test/unit/utils/ewma-rate-limiter.spec.ts', 6], + ['test/unit/utils/sliding-window-rate-limiter.spec.ts', 3], + ['test/unit/factories/event-strategy-factory.spec.ts', 25], + ['test/unit/factories/message-handler-factory.spec.ts', 6], + ['test/unit/factories/websocket-adapter-factory.spec.ts', 1], + ['test/unit/schemas/event-schema.spec.ts', 57], + ['test/unit/schemas/message-schema.spec.ts', 17], + ['test/unit/utils/hidden-content-cache.spec.ts', 12], + ['test/unit/utils/shutdown-state.spec.ts', 5], + ['test/unit/utils/validation.spec.ts', 4], +] + +const cwd = process.cwd() +const isCaptureTree = + fs.existsSync(path.join(cwd, 'src/utils/messages.ts')) && + fs.existsSync(path.join(cwd, 'test/unit/handlers/event-message-handler.spec.ts')) && + !fs.existsSync(path.join(cwd, 'crates')) +if (!isCaptureTree) { + console.error( + `run this from the capture tree (cwd=${cwd}); src/utils/messages.ts must exist and crates/ must not`, + ) + process.exit(2) +} + +const outDirFlag = process.argv.indexOf('--out-dir') +const outDir = + outDirFlag === -1 + ? path.resolve(path.dirname(new URL(import.meta.url).pathname), '../fixtures') + : path.resolve(process.argv[outDirFlag + 1]) + +const report = path.join(fs.mkdtempSync('/tmp/census-'), 'report.json') + +execFileSync( + './node_modules/.bin/mocha', + [ + '--no-config', + '--reporter', + 'json', + '--reporter-option', + `output=${report}`, + '-r', + 'ts-node/register', + ...FILES.map(([file]) => file), + ], + { stdio: ['ignore', 'ignore', 'inherit'], env: process.env }, +) + +const runner = JSON.parse(fs.readFileSync(report, 'utf8')) + +const byFile = new Map(FILES.map(([file]) => [file, []])) +for (const test of runner.tests) { + const file = test.file.replace(/^.*?\/test\//, 'test/') + if (!byFile.has(file)) { + console.error(`a case ran from a file outside the census: ${file}`) + process.exit(1) + } + byFile.get(file).push(test.fullTitle) +} + +const files = [] +let total = 0 +for (const [file, expected] of FILES) { + const titles = byFile.get(file) + + if (titles.length !== expected) { + console.error(`${file}: ${titles.length} cases, the Context table says ${expected}`) + process.exit(1) + } + + files.push({ file, cases: titles.length, titles }) + total += titles.length +} + +const fixture = { + capture: CAPTURE, + generated_by: 'crates/nostream-relay/tests/capture/gen-ts-cases.mjs', + total, + files, +} + +const target = path.join(outDir, 'ts-cases.json') +fs.writeFileSync(target, `${JSON.stringify(fixture, null, 2)}\n`) + +console.log(`${total} cases from ${files.length} files -> ${target}`) diff --git a/crates/nostream-relay/tests/capture/gen-wire-sessions.mjs b/crates/nostream-relay/tests/capture/gen-wire-sessions.mjs new file mode 100644 index 000000000..48620ddff --- /dev/null +++ b/crates/nostream-relay/tests/capture/gen-wire-sessions.mjs @@ -0,0 +1,1024 @@ +// Regenerates ../fixtures/wire-sessions.json: the frame-session recording the Rust relay is +// replayed against (R31). +// +// Subject: cameri/nostream at 242a3001d77149e4d70151ec03060c4ef44a1547 — the capture commit +// this branch is cut from. The harness MUST run with that tree as its working directory, +// because it loads the capture's real adapters, handlers and shipped settings from . +// +// One-time setup, in a scratch directory (never in the working tree): +// git archive 242a3001 | tar -x -C /tmp/nostream-capture +// ln -s /node_modules /tmp/nostream-capture/node_modules +// +// Run, from the capture tree: +// NODE_OPTIONS=--no-experimental-strip-types TS_NODE_TRANSPILE_ONLY=true \ +// node -r ts-node/register [--out-dir /crates/nostream-relay/tests/fixtures] +// +// What it does. For each of the sixteen sessions R31 names, it: +// 1. installs the session's settings document over the capture's shipped defaults, +// 2. stands up the real WebSocketServerAdapter + WebSocketAdapter + five per-message +// handlers on 127.0.0.1:0 over in-memory repository doubles, +// 3. connects one `ws` client and replays the session's script one step at a time, +// recording every frame the relay sent, in order, +// 4. records the session's settings, its steps, and the interval or close the session needs. +// +// Determinism (R29). The clock is frozen at CLOCK_START_MS with `node:test`'s mocked `Date`, and +// `crypto.randomBytes` is replaced with SplitMix64 over SEED — the stream +// `nostream_core::random::SeededRandom` implements byte for byte. The one client frame that +// carries the NIP-42 challenge records it as `{{challenge}}`, and the server's AUTH frame masks +// it the same way, so the replay proves the relay verifies its own challenge. Because both +// sides draw the same challenge, the recorded AUTH event's id and signature stay valid in the +// replay; a divergence in the stream shows up as a failed AUTH, not a silent pass. +// +// Client events are signed here with a fixed key, `sha256("nostream-008-wire")`, so the recorded +// frames carry their ids and signatures and the replay only sends text. +import fs from 'node:fs' +import path from 'node:path' +import http from 'node:http' +import { Readable } from 'node:stream' +import { createHash } from 'node:crypto' +import { createRequire } from 'node:module' +import { mock } from 'node:test' + +const CAPTURE = '242a3001d77149e4d70151ec03060c4ef44a1547' +const CLOCK_START_MS = 1700000000000 +const SEED = 20261023 +const RELAY_SECRET = createHash('sha256').update('nostream-008-relay').digest('hex') +const NODE_MODULES = '/workspace/projects/nostream/node_modules' + +const cwd = process.cwd() +const isCaptureTree = + fs.existsSync(path.join(cwd, 'src/adapters/web-socket-adapter.ts')) && + fs.existsSync(path.join(cwd, 'resources/default-settings.yaml')) && + !fs.existsSync(path.join(cwd, 'crates')) +if (!isCaptureTree) { + console.error(`run this from the capture tree (cwd=${cwd}); src/adapters must exist and crates/ must not`) + process.exit(2) +} + +// ---- fixture randomness: SplitMix64, little-endian, eight bytes per draw --------------- +const MASK64 = (1n << 64n) - 1n +let rngState = BigInt(SEED) +const nextRandom = () => { + rngState = (rngState + 0x9e3779b97f4a7c15n) & MASK64 + let z = rngState + z = ((z ^ (z >> 30n)) * 0xbf58476d1ce4e5b9n) & MASK64 + z = ((z ^ (z >> 27n)) * 0x94d049bb133111ebn) & MASK64 + return (z ^ (z >> 31n)) & MASK64 +} +const seededBytes = (size) => { + const out = Buffer.alloc(size) + let offset = 0 + while (offset < size) { + const draw = nextRandom() + for (let index = 0; index < 8 && offset < size; index += 1) { + out[offset] = Number((draw >> BigInt(index * 8)) & 0xffn) + offset += 1 + } + } + return out +} + +const cryptoModule = createRequire(import.meta.url)('node:crypto') +const originalRandomBytes = cryptoModule.randomBytes +cryptoModule.randomBytes = seededBytes + +// ---- the frozen clock ----------------------------------------------------------------- +mock.timers.enable({ apis: ['Date'], now: CLOCK_START_MS }) +const advanceClock = (milliseconds) => mock.timers.tick(milliseconds) + +// ---- the capture ---------------------------------------------------------------------- +const requireTs = createRequire(path.join(cwd, 'index.ts')) +const { SettingsStatic } = requireTs('./src/utils/settings') +const { WebSocketAdapter } = requireTs('./src/adapters/web-socket-adapter') +const { WebSocketServerAdapter } = requireTs('./src/adapters/web-socket-server-adapter') +const { EventMessageHandler } = requireTs('./src/handlers/event-message-handler') +const { SubscribeMessageHandler } = requireTs('./src/handlers/subscribe-message-handler') +const { CountMessageHandler } = requireTs('./src/handlers/count-message-handler') +const { AuthMessageHandler } = requireTs('./src/handlers/auth-message-handler') +const { UnsubscribeMessageHandler } = requireTs('./src/handlers/unsubscribe-message-handler') +const { eventStrategyFactory } = requireTs('./src/factories/event-strategy-factory') +const { serializeEvent } = requireTs('./src/utils/event') +const { getLeadingZeroBits } = requireTs('./src/utils/proof-of-work') +const { startHiddenContentCache, resetHiddenContentCache } = requireTs('./src/utils/hidden-content-cache') + +const nodeRequire = createRequire(import.meta.url) +const WebSocket = nodeRequire(`${NODE_MODULES}/ws`) +const { WebSocketServer } = nodeRequire(`${NODE_MODULES}/ws`) +const secp = nodeRequire(`${NODE_MODULES}/@noble/secp256k1`) + +secp.utils.sha256Sync = (...messages) => { + const hash = createHash('sha256') + for (const message of messages) hash.update(message) + return hash.digest() +} + +// The shipped document, snapshotted before any session installs its own: `createSettings` +// caches, so a later read would return the session that ran last. +const PRISTINE = JSON.parse(JSON.stringify(SettingsStatic.createSettings())) +const shippedSettings = () => JSON.parse(JSON.stringify(PRISTINE)) + +// ---- in-memory collaborators (A7: doubles, never a database) -------------------------- +const notFound = async () => undefined +const zero = async () => 0 +const empty = async () => [] + +const eventRow = (event) => ({ + id: `row-${event.id.slice(0, 8)}`, + event_id: Buffer.from(event.id, 'hex'), + event_kind: event.kind, + event_pubkey: Buffer.from(event.pubkey, 'hex'), + event_created_at: event.created_at, + event_content: event.content, + event_tags: event.tags, + event_signature: Buffer.from(event.sig, 'hex'), + first_seen: new Date(CLOCK_START_MS), + deleted_at: null, + expires_at: null, + remote_address: null, +}) + +// The capture emits a stored event in this order; the doubles' rows follow it so the replay can +// compare read-back frames byte-for-byte. +const rowToEvent = (row) => ({ + id: row.event_id.toString('hex'), + pubkey: row.event_pubkey.toString('hex'), + created_at: row.event_created_at, + kind: row.event_kind, + tags: row.event_tags, + content: row.event_content, +}) + +const matches = (filter, event) => { + if (filter.ids && !filter.ids.includes(event.id)) return false + if (filter.kinds && !filter.kinds.includes(event.kind)) return false + if (filter.authors && !filter.authors.includes(event.pubkey)) return false + if (filter.since !== undefined && event.created_at < filter.since) return false + if (filter.until !== undefined && event.created_at > filter.until) return false + for (const key of Object.keys(filter)) { + if (!key.startsWith('#')) continue + const name = key.slice(1) + const wanted = filter[key] + const present = (event.tags || []).filter((tag) => tag[0] === name).map((tag) => tag[1]) + if (!wanted.some((value) => present.includes(value))) return false + } + return true +} + +const makeDoubles = () => { + const rows = new Map() + const asRow = (value) => (typeof value.id === 'string' && !value.event_id ? eventRow(value) : value) + + const state = { + rows, + minted: [], + vanished: new Set(), + verified: new Map(), + actionable: [], + blocked: new Set(), + balance: 0, + } + + const eventRepository = { + create: async (value) => { + const row = asRow(value) + const id = row.event_id.toString('hex') + if (rows.has(id)) return 0 + rows.set(id, row) + return 1 + }, + createMany: async (list) => list.length, + upsert: async (value) => { + const row = asRow(value) + rows.set(row.event_id.toString('hex'), row) + return 1 + }, + upsertMany: async (list) => list.length, + deleteByPubkeyAndIds: async () => 0, + deleteByPubkeyExceptKinds: async () => 0, + countByFilters: async (filters) => { + let total = 0 + for (const row of [...rows.values()]) { + if (filters.some((filter) => matches(filter, rowToEvent(row)))) total += 1 + } + return total + }, + findByFilters: (filters) => ({ + stream: () => + Readable.from( + [...rows.values()] + .filter((row) => filters.some((filter) => matches(filter, rowToEvent(row)))) + .map((row) => ({ ...row })), + ), + }), + } + + // `findByPubkey` has no row for a pubkey the session does not name, and `isAdmitted` has no + // opinion for it: the capture's admission check then allows the event (payments are off in + // every session but the rejections one). + const userRepository = { + findByPubkey: async (pubkey) => + state.blocked.has(pubkey) + ? { pubkey, balance: state.balance, admitted: false } + : undefined, + upsert: zero, + admitUser: zero, + revokeAdmission: zero, + isVanished: async (pubkey) => state.vanished.has(pubkey), + setVanished: zero, + getBalanceByPubkey: async () => state.balance, + isAdmitted: async (pubkey) => (state.blocked.has(pubkey) ? false : undefined), + } + const nip05Repository = { + findByPubkey: async (pubkey) => state.verified.get(pubkey), + deleteByPubkey: zero, + upsert: zero, + } + const inviteCodeRepository = { + claimCode: async () => true, + find: notFound, + create: async (code, options) => { + state.minted.push({ code, options }) + return { code, ...options } + }, + findByCode: notFound, + findActiveCodes: empty, + deleteExpiredCodes: zero, + } + const dvmJobRepository = { + create: async (job) => job, + updateStatus: notFound, + findById: notFound, + assignWorker: async () => false, + findPendingJobs: empty, + } + const reportRepository = { + create: async () => ({}), + createMany: empty, + findByEventId: empty, + findActionable: empty, + findActionableTargets: async () => state.actionable, + } + + return { + state, + eventRepository, + userRepository, + nip05Repository, + inviteCodeRepository, + dvmJobRepository, + reportRepository, + } +} + +// The capture reaches Redis through module-level limiters. Here they are substituted, and the +// session says which of the two trips; the recorded settings are what the Rust side derives the +// same verdict from (`rate: 0` with the window's period). +let tripping = { message: false, event: false } +const adapterLimiter = { hit: async (key) => tripping.message && String(key).includes(':message:') } +const eventLimiter = { hit: async (key) => tripping.event && String(key).includes(':events:') } +// The capture's ICacheAdapter, over a Map: the admission check reads and writes it, so a +// missing method would throw inside the EVENT handler and lose the OK frame. +const store = new Map() +const cache = { + getKey: async (key) => (store.has(key) ? store.get(key) : null), + hasKey: async (key) => store.has(key), + setKey: async (key, value) => { + store.set(key, value) + return true + }, + setKeyIfNotExists: async (key, value) => { + if (store.has(key)) return false + store.set(key, value) + return true + }, + addToSortedSet: async () => 0, + removeRangeByScoreFromSortedSet: async () => 0, + getRangeFromSortedSet: async () => [], + setKeyExpiry: async () => undefined, + deleteKey: async (key) => { + store.delete(key) + return 1 + }, + getHKey: async () => null, + setHKey: async () => true, + addToSet: async () => 0, + getSetMembers: async () => [], + eval: async () => 0, + get: async () => null, + set: async () => undefined, + del: async () => 0, + exists: async () => false, + zadd: async () => 0, + zremrangebyscore: async () => 0, + zrange: empty, + hgetall: async () => ({}), + hset: async () => 0, + expire: async () => 0, + incr: async () => 1, + publish: async () => 0, + subscribe: async () => undefined, +} +const wotGraphService = { + isTrusted: async () => false, + getScore: async () => 0, + suggest: async () => 0, + update: async () => undefined, + score: async () => 0, +} + +// ---- the client keys ------------------------------------------------------------------ +const CLIENT_SECRET = createHash('sha256').update('nostream-008-wire').digest('hex') +const OTHER_SECRET = createHash('sha256').update('nostream-008-other').digest('hex') +const pubkeyOf = (secret) => Buffer.from(secp.getPublicKey(secret, true)).toString('hex').substring(2) +const CLIENT_PUBKEY = pubkeyOf(CLIENT_SECRET) +const OTHER_PUBKEY = pubkeyOf(OTHER_SECRET) +const RELAY_PUBKEY = pubkeyOf(RELAY_SECRET) + +// A key whose pubkey already carries one leading zero bit, so the pubkey PoW gate passes once +// the session turns it on and an event can reach the gates behind it. +const MINED_SECRET = (() => { + for (let counter = 0; ; counter += 1) { + const candidate = createHash('sha256').update(`nostream-008-mined-${counter}`).digest('hex') + const pubkey = pubkeyOf(candidate) + if (getLeadingZeroBits(Buffer.from(pubkey, 'hex')) >= 1) return candidate + } +})() +const MINED_PUBKEY = pubkeyOf(MINED_SECRET) + +const NOW_SECS = Math.floor(CLOCK_START_MS / 1000) + +const sign = (event, secret = CLIENT_SECRET) => { + const id = createHash('sha256').update(Buffer.from(JSON.stringify(serializeEvent(event)))).digest() + event.id = id.toString('hex') + // A fixed aux rand keeps signing out of the fixture's random stream: only the challenge + // and the invite code draw from it. + event.sig = Buffer.from(secp.schnorr.signSync(id, secret, '00'.repeat(32))).toString('hex') + return event +} + +const pubkeyOfSecret = (secret) => + secret === CLIENT_SECRET ? CLIENT_PUBKEY : secret === OTHER_SECRET ? OTHER_PUBKEY : MINED_PUBKEY + +const makeEvent = (over = {}, secret = CLIENT_SECRET) => + sign( + { + pubkey: pubkeyOfSecret(secret), + kind: 1, + created_at: NOW_SECS, + content: 'wire-session', + tags: [], + ...over, + }, + secret, + ) + +/// An event whose id carries `bits` leading zero bits, by varying its content. +const grind = (event, bits = 4, secret = MINED_SECRET) => { + for (let nonce = 0; nonce < 10000; nonce += 1) { + const candidate = makeEvent({ ...event, content: `ground-${nonce}` }, secret) + if (getLeadingZeroBits(Buffer.from(candidate.id, 'hex')) >= bits) return candidate + } + throw new Error('grind gave up') +} + +const AUTH_RE = /"AUTH","([A-Za-z0-9_-]+)"/g +const maskChallenge = (frame) => frame.replace(AUTH_RE, '"AUTH","{{challenge}}"') + +// ---- one session ---------------------------------------------------------------------- +const sessions = [] + +const settingsFor = (overrides) => { + const document = shippedSettings() + + const merge = (target, source) => { + for (const [key, value] of Object.entries(source)) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + target[key] = merge(target[key] && typeof target[key] === 'object' ? target[key] : {}, value) + } else { + target[key] = value + } + } + return target + } + + return merge(document, overrides) +} + +// The capture caches its settings in a static, and its watcher can replace that object. Each +// session installs its own document as a whole, so nothing leaks between sessions. +const installSettings = (document) => { + SettingsStatic._settings = JSON.parse(JSON.stringify(document)) +} + +const LOOPBACK = ['127.0.0.1', '::1', '::ffff:127.0.0.1'] + +const startServer = async (definition) => { + const document = definition.settings + installSettings(document) + + const doubles = makeDoubles() + const { eventRepository, userRepository, nip05Repository, inviteCodeRepository } = doubles + const { dvmJobRepository, reportRepository } = doubles + const settings = () => SettingsStatic.createSettings() + + const createMessageHandler = ([message, adapter]) => { + switch (message[0]) { + case 'EVENT': + return new EventMessageHandler( + adapter, + eventStrategyFactory( + eventRepository, + userRepository, + inviteCodeRepository, + dvmJobRepository, + reportRepository, + cache, + settings, + ), + eventRepository, + userRepository, + settings, + nip05Repository, + cache, + () => eventLimiter, + wotGraphService, + ) + case 'REQ': + return new SubscribeMessageHandler(adapter, eventRepository, settings, inviteCodeRepository, () => eventLimiter) + case 'CLOSE': + return new UnsubscribeMessageHandler(adapter) + case 'COUNT': + return new CountMessageHandler(adapter, eventRepository, settings) + case 'AUTH': + return new AuthMessageHandler(adapter, settings) + default: + throw new Error(`Unknown message type: ${String(message[0]).substring(0, 64)}`) + } + } + + const createWebSocketAdapter = ([client, request, serverAdapter]) => + new WebSocketAdapter(client, request, serverAdapter, createMessageHandler, () => adapterLimiter, settings) + + const restoreInterval = globalThis.setInterval + if (definition.livenessIntervalMs) { + globalThis.setInterval = (fn, delay, ...rest) => + restoreInterval(fn, delay >= 30000 ? definition.livenessIntervalMs : delay, ...rest) + } + + const server = http.createServer() + const wss = new WebSocketServer({ + server, + maxPayload: settings().network?.maxPayloadSize ?? 131072, + perMessageDeflate: false, + }) + const serverAdapter = new WebSocketServerAdapter(server, wss, createWebSocketAdapter, settings) + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + + resetHiddenContentCache() + if (settings().nip56?.hideActionableReports) { + await startHiddenContentCache(reportRepository, settings) + } + + globalThis.setInterval = restoreInterval + + return { + doubles, + port: server.address().port, + serverAdapter, + close: () => + new Promise((resolve) => { + serverAdapter.close(() => server.close(() => resolve())) + }), + } +} + +const runSession = async (definition) => { + const secret = process.env.SECRET + if (definition.withoutSecret) { + delete process.env.SECRET + delete process.env.RELAY_PRIVATE_KEY + } + + const server = await startServer({ ...definition, settings: definition.settings }) + const { state } = server.doubles + + for (const target of definition.actionable ?? []) state.actionable.push(target) + for (const pubkey of definition.vanished ?? []) state.vanished.add(pubkey) + for (const [pubkey, verification] of Object.entries(definition.verified ?? {})) { + state.verified.set(pubkey, verification) + } + for (const pubkey of definition.blocked ?? []) state.blocked.add(pubkey) + + const options = { perMessageDeflate: Boolean(definition.offerCompression) } + if (definition.autoPong === false) options.autoPong = false + const socket = new WebSocket(`ws://127.0.0.1:${server.port}`, options) + + let responseHeaders = {} + socket.on('upgrade', (response) => { + responseHeaders = response.headers + }) + + // The listener goes on before the handshake completes: the relay's AUTH frame races `open`. + const frames = [] + socket.on('message', (data) => frames.push(data.toString())) + + let closed = null + socket.on('close', (code, reason) => { + closed = { code, reason: reason.toString() } + }) + + await new Promise((resolve, reject) => { + socket.once('open', resolve) + socket.once('error', reject) + }) + + // `Date` is mocked, so every wait counts attempts rather than reading the clock. + const take = async (count, attempts = 300) => { + const out = [] + while (out.length < count && frames.length) { + out.push(frames.shift()) + } + for (let attempt = 0; attempt < attempts && out.length < count; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 5)) + while (out.length < count && frames.length) out.push(frames.shift()) + } + return out + } + + const steps = [] + const first = await take(1) + steps.push({ connect: true, expect: first.map(maskChallenge) }) + const challenge = JSON.parse(first[0])[1] + + const send = async (payload, options = {}) => { + const text = typeof payload === 'string' ? payload : JSON.stringify(payload) + socket.send(text) + + const step = { send: { text: text.split(challenge).join('{{challenge}}') } } + if (options.allow) step.allow = options.allow + if (options.compare === false) { + step.compare = false + step.compare_why = options.why + } + if (options.advanceMs) step.advance_ms = options.advanceMs + if (options.expect) step.expect = (await take(options.expect, options.attempts)).map(maskChallenge) + if (options.none) { + await new Promise((resolve) => setTimeout(resolve, 250)) + step.expect = frames.splice(0).map(maskChallenge) + } + steps.push(step) + return step.expect ?? [] + } + + const context = { + socket, + send, + steps, + challenge, + state, + settings: () => SettingsStatic.createSettings(), + advanceClock, + waitForClose: async (attempts = 600) => { + for (let attempt = 0; attempt < attempts && !closed; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 5)) + } + return closed + }, + platform: { server, frames, take }, + } + + await definition.script(context) + + if (definition.drain) { + const before = frames.length + await new Promise((resolve) => server.serverAdapter.close(() => resolve())) + // The adapter's close drains: the CLOSED frames and the 1001 close arrive before the + // callback, but give the socket a moment to hand them over. + await new Promise((resolve) => setTimeout(resolve, 300)) + steps.push({ drain: true, expect: frames.splice(0).map(maskChallenge) }) + if (frames.length !== before) throw new Error('drain frames were consumed twice') + } + + const session = { + id: definition.id, + challenge, + clock: { start_ms: CLOCK_START_MS }, + seed: SEED, + settings: JSON.parse(JSON.stringify(definition.settings)), + steps, + } + + if (definition.livenessIntervalMs) session.liveness_interval_ms = definition.livenessIntervalMs + if (definition.finalClose) { + const info = await context.waitForClose() + session.final_close = info ?? { code: 'open' } + } + if (definition.offerCompression) { + session.handshake = { + extensions: 'permessage-deflate; client_max_window_bits', + response_extensions: responseHeaders['sec-websocket-extensions'] ?? null, + } + } + if (!definition.withoutSecret) session.relay_secret = RELAY_SECRET + if (state.actionable.length) session.actionable = state.actionable + + // The two values the relay minted from its own randomness: the challenge this socket was + // given, and (for a mint) the invite code. The replay hands the relay exactly these bytes. + session.random = { + challenge: Buffer.from(challenge, 'base64url').toString('hex'), + invite_code: state.minted.length ? state.minted[0].code : null, + } + + sessions.push(session) + + try { + socket.terminate() + } catch {} + await server.close() + if (secret !== undefined) { + process.env.SECRET = secret + process.env.RELAY_PRIVATE_KEY = secret + } + tripping = { message: false, event: false } + console.error(`recorded ${definition.id}: ${steps.length} steps`) +} + +// ---- the sixteen sessions ------------------------------------------------------------- +const baseLimits = () => shippedSettings().limits +const noConnectionLimit = (limits) => { + const next = JSON.parse(JSON.stringify(limits)) + next.connection = { ...next.connection, ipWhitelist: LOOPBACK } + return next +} + +// The document that lets the rejections session reach every gate it names, in the pipeline's +// order: the timestamp window and content cap are tight, PoW is on, and admission/NIP-05 only +// bite for the last two steps because earlier steps fail sooner. +const rejectionLimits = () => { + const limits = JSON.parse(JSON.stringify(baseLimits())) + limits.connection = { ...limits.connection, ipWhitelist: LOOPBACK } + limits.event.createdAt = { maxPositiveDelta: 60, maxNegativeDelta: 60 } + limits.event.content = [{ description: 'small', maxLength: 16 }] + limits.event.eventId = { minLeadingZeroBits: 4 } + limits.event.pubkey = { ...limits.event.pubkey, minLeadingZeroBits: 1, blacklist: [] } + limits.event.kind = { whitelist: [], blacklist: [7] } + limits.event.rateLimits = [] + return limits +} + +const runAll = async () => { + // 10. No SECRET: an EVENT cannot reach a strategy. This session runs first, because the + // capture memoises the relay key on first use and later sessions need it. + await runSession({ + id: 'no-secret', + settings: settingsFor({ limits: noConnectionLimit(baseLimits()) }), + withoutSecret: true, + script: async ({ send }) => { + await send(['EVENT', makeEvent({ content: 'no secret' })], { expect: 1, attempts: 600 }) + }, + }) + + // 1. Every frame of recon.md's quoted transcript, in order, ending with the payload close. + await runSession({ + id: 'measured-session', + settings: settingsFor({ limits: noConnectionLimit(baseLimits()) }), + script: async ({ send, socket, settings, challenge, steps }) => { + const good = makeEvent() + await send('{not json', { + expect: 1, + allow: [{ why: 'T1', detail: 'the JSON syntax message is V8-specific; the prefix is not' }], + }) + await send(['BOGUS', 'x'], { expect: 1 }) + await send(['REQ', 's-empty'], { expect: 1 }) + await send(['REQ', 's1', { kinds: [1] }], { expect: 1 }) + await send(['COUNT', 'c1', { kinds: [1] }], { expect: 1 }) + await send(['REQ', 's1', { kinds: [1] }], { expect: 1 }) + await send(['CLOSE', 's1'], { none: true }) + await send(['EVENT', good], { expect: 1 }) + await send(['EVENT', good], { expect: 1 }) + await send(['EVENT', { ...good, content: 'tampered' }], { expect: 1 }) + const wrongKind = makeEvent({ kind: 22242, tags: [['relay', settings().info.relay_url], ['challenge', 'nope']] }) + await send(['AUTH', wrongKind], { expect: 1 }) + const goodAuth = makeEvent({ + kind: 22242, + tags: [['relay', settings().info.relay_url], ['challenge', challenge]], + }) + await send(['AUTH', goodAuth], { expect: 1 }) + // A read-back EVENT frame carries the capture's own field order for stored rows + // (`id, kind, pubkey, created_at, content, tags, sig`), which differs from the order its + // broadcast path emits; recorded, not byte-compared. + await send(['REQ', 's2', { kinds: [1] }], { + expect: 2, + compare: false, + why: 'a REQ result carries the capture’s stored-row field order; recorded, not compared — job 008', + }) + // The oversized frame: recorded as its size, not its 600 KB of text. + socket.send(JSON.stringify(['EVENT', makeEvent({ content: 'x'.repeat(600 * 1024) })])) + steps.push({ oversized: 600 * 1024 + 200 }) + }, + finalClose: true, + }) + + // 2. The message limiter: REQ/COUNT are CLOSED with the window's period, EVENT is a NOTICE. + await runSession({ + id: 'rate-limit-message', + settings: settingsFor({ + limits: { + ...noConnectionLimit(baseLimits()), + message: { rateLimits: [{ period: 60000, rate: 0 }], ipWhitelist: [] }, + }, + }), + script: async ({ send }) => { + tripping = { message: true, event: false } + await send(['REQ', 's1', { kinds: [1] }], { expect: 1 }) + await send(['COUNT', 'c1', { kinds: [1] }], { expect: 1 }) + await send(['EVENT', makeEvent()], { expect: 1 }) + await send(['REQ', 's2', { kinds: [1] }], { expect: 1 }) + await send(['CLOSE', 's1'], { none: true }) + }, + }) + + // 3. The event limiter: the OK carries the window's period as the backoff. + await runSession({ + id: 'rate-limit-event', + settings: settingsFor({ + limits: { + ...noConnectionLimit(baseLimits()), + event: { ...noConnectionLimit(baseLimits()).event, rateLimits: [{ period: 60000, rate: 0 }] }, + }, + }), + script: async ({ send }) => { + tripping = { message: false, event: true } + await send(['EVENT', makeEvent({ content: 'limited' })], { expect: 1 }) + await send(['EVENT', makeEvent({ content: 'again' })], { expect: 1 }) + }, + }) + + // 4. Both whitelists: loopback bypasses the message limiter, the pubkey bypasses the event one. + await runSession({ + id: 'whitelists', + settings: settingsFor({ + limits: { + ...noConnectionLimit(baseLimits()), + message: { rateLimits: [{ period: 60000, rate: 0 }], ipWhitelist: LOOPBACK }, + event: { + ...noConnectionLimit(baseLimits()).event, + rateLimits: [{ period: 60000, rate: 0 }], + whitelists: { pubkeys: [CLIENT_PUBKEY], ipAddresses: [] }, + }, + }, + }), + script: async ({ send }) => { + tripping = { message: true, event: true } + await send(['REQ', 'w1', { kinds: [1] }], { expect: 1 }) + await send(['EVENT', makeEvent({ content: 'whitelisted' })], { expect: 1 }) + await send(['EVENT', makeEvent({ content: 'not whitelisted' }, OTHER_SECRET)], { expect: 1 }) + }, + }) + + // 5. A restricted-only REQ from an unauthenticated client, then the same REQ after AUTH. + await runSession({ + id: 'req-auth-required', + settings: settingsFor({ + limits: noConnectionLimit(baseLimits()), + nip42: { ...shippedSettings().nip42, restrictedReads: { enabled: true, kinds: [4] } }, + }), + script: async ({ send, settings, challenge }) => { + await send(['REQ', 'r1', { kinds: [4] }], { expect: 1 }) + const auth = makeEvent({ + kind: 22242, + tags: [['relay', settings().info.relay_url], ['challenge', challenge]], + }) + await send(['AUTH', auth], { expect: 1 }) + await send(['REQ', 'r2', { kinds: [4] }], { expect: 1 }) + }, + }) + + // 6. CLOSE emits nothing; a later REQ still serves. + await runSession({ + id: 'req-close-abort', + settings: settingsFor({ limits: noConnectionLimit(baseLimits()) }), + script: async ({ send }) => { + await send(['REQ', 'c1', { kinds: [1] }], { expect: 1 }) + await send(['CLOSE', 'c1'], { none: true }) + await send(['REQ', 'c2', { kinds: [1] }], { expect: 1 }) + }, + }) + + // 7. NIP-43: an authenticated REQ for kind 28935 mints one claim event before EOSE. + await runSession({ + id: 'invite-mint', + relaySecret: true, + settings: settingsFor({ + limits: noConnectionLimit(baseLimits()), + info: { ...shippedSettings().info, pubkey: RELAY_PUBKEY, self: RELAY_PUBKEY }, + nip43: { + ...shippedSettings().nip43, + enabled: true, + allowInviteRequests: true, + inviteRequestWhitelist: [], + }, + }), + script: async ({ send, settings, challenge }) => { + const auth = makeEvent({ + kind: 22242, + tags: [['relay', settings().info.relay_url], ['challenge', challenge]], + }) + await send(['AUTH', auth], { expect: 1 }) + // The capture's invite-code builder emits its own key order + // (`pubkey, kind, created_at, content, tags, id, sig`) while every other EVENT frame uses + // the read-back order, so this frame is recorded but not byte-compared. + await send(['REQ', 'invite', { kinds: [28935] }], { + expect: 2, + compare: false, + why: 'the capture builds the claim event with its own field order; recorded, not compared — job 008', + }) + }, + }) + + // 8. Two overlapping subscriptions both receive a live event. + await runSession({ + id: 'live-dispatch', + settings: settingsFor({ limits: noConnectionLimit(baseLimits()) }), + script: async ({ send }) => { + await send(['REQ', 'a', {}], { expect: 1 }) + await send(['REQ', 'b', { kinds: [1] }], { expect: 1 }) + await send(['EVENT', makeEvent({ content: 'live' })], { expect: 3 }) + }, + }) + + // 9. An actionable report hides its target from the live broadcast. + await runSession({ + id: 'hidden-report', + settings: settingsFor({ + limits: noConnectionLimit(baseLimits()), + nip56: { ...shippedSettings().nip56, enabled: true, hideActionableReports: true }, + }), + actionable: [], + script: async ({ send, state }) => { + const hidden = makeEvent({ content: 'hidden' }) + state.actionable.push({ reportedPubkey: null, reportedEventId: hidden.id }) + // The cache was seeded at boot; mark the target by refreshing through the same path the + // harness uses: the boot seed ran before the target existed, so the tick is driven here. + const { startHiddenContentCache } = requireTs('./src/utils/hidden-content-cache') + await startHiddenContentCache( + { findActionableTargets: async () => state.actionable }, + () => SettingsStatic.createSettings(), + ) + await send(['REQ', 'h1', {}], { expect: 1 }) + await send(['EVENT', hidden], { expect: 1 }) + await send(['EVENT', makeEvent({ content: 'visible' })], { expect: 3 }) + }, + }) + + // 11. The drain: one CLOSED per subscription, then close 1001. + await runSession({ + id: 'drain', + settings: settingsFor({ limits: noConnectionLimit(baseLimits()) }), + script: async ({ send }) => { + await send(['REQ', 'd1', { kinds: [1] }], { expect: 1 }) + await send(['REQ', 'd2', { kinds: [4] }], { expect: 1 }) + }, + drain: true, + }) + + // 12. A client that never pongs is closed with no close frame. + await runSession({ + id: 'liveness', + livenessIntervalMs: 20, + autoPong: false, + settings: settingsFor({ limits: noConnectionLimit(baseLimits()) }), + script: async ({ platform }) => { + await new Promise((resolve) => setTimeout(resolve, 600)) + platform.frames.splice(0) + }, + finalClose: true, + }) + + // 13. A permessage-deflate offer is declined and the session stays uncompressed. + await runSession({ + id: 'compression-offer', + offerCompression: true, + settings: settingsFor({ limits: noConnectionLimit(baseLimits()) }), + script: async ({ send }) => { + await send(['REQ', 'z1', { kinds: [1] }], { expect: 1 }) + }, + }) + + // 14. COUNT with NIP-45 disabled. + await runSession({ + id: 'count-disabled', + settings: settingsFor({ + limits: noConnectionLimit(baseLimits()), + nip45: { ...shippedSettings().nip45, enabled: false }, + }), + script: async ({ send }) => { + await send(['COUNT', 'c1', { kinds: [1] }], { expect: 1 }) + }, + }) + + // 15. An authenticated session expires after sessionExpirySeconds. + await runSession({ + id: 'nip42-expiry', + settings: settingsFor({ + limits: noConnectionLimit(baseLimits()), + nip42: { + ...shippedSettings().nip42, + sessionExpirySeconds: 1, + restrictedReads: { enabled: true, kinds: [4] }, + }, + }), + script: async ({ send, settings, challenge, advanceClock }) => { + const auth = makeEvent({ + kind: 22242, + tags: [['relay', settings().info.relay_url], ['challenge', challenge]], + }) + await send(['AUTH', auth], { expect: 1 }) + await send(['REQ', 'e1', { kinds: [4] }], { expect: 1 }) + advanceClock(1000) + await send(['REQ', 'e2', { kinds: [4] }], { expect: 1, advanceMs: 1000 }) + }, + }) + + // 16. One step per EVENT reason string, in the pipeline's order. + await runSession({ + id: 'event-rejections', + vanished: [OTHER_PUBKEY], + verified: {}, + blocked: [MINED_PUBKEY], + settings: settingsFor({ + limits: rejectionLimits(), + nip43: { ...shippedSettings().nip43, enabled: true, allowInviteRequests: false }, + }), + script: async ({ send }) => { + const good = makeEvent({ kind: 1, content: 'ok', tags: [] }) + // 1. id / 2. signature: tamper after signing. + await send(['EVENT', { ...good, content: 'id-mismatch' }], { expect: 1 }) + const signed = makeEvent({ content: 'sig' }) + await send(['EVENT', { ...signed, sig: '0'.repeat(128) }], { expect: 1 }) + // 3. NIP-17 inner kinds. + await send(['EVENT', makeEvent({ kind: 13, content: 'seal', tags: [] })], { expect: 1 }) + await send(['EVENT', makeEvent({ kind: 444, content: 'rumor', tags: [] })], { expect: 1 }) + // 4. an AUTH event over EVENT. + await send(['EVENT', makeEvent({ kind: 22242, tags: [] })], { expect: 1 }) + // 5. the request-to-vanish relay tag. + await send(['EVENT', makeEvent({ kind: 62, tags: [['relay', 'wss://relay.example.com']] })], { expect: 1 }) + // 6. expired. + await send(['EVENT', makeEvent({ tags: [['expiration', String(NOW_SECS - 1)]] })], { expect: 1 }) + // 7. content over the cap. + await send(['EVENT', makeEvent({ content: 'x'.repeat(64) })], { expect: 1 }) + // 8. created_at too far ahead, then too far behind. + await send(['EVENT', makeEvent({ created_at: NOW_SECS + 3600 })], { expect: 1 }) + await send(['EVENT', makeEvent({ created_at: NOW_SECS - 3600 })], { expect: 1 }) + // 9. PoW on the event id, that is: an unground id. + await send(['EVENT', makeEvent({ content: 'not-ground' })], { expect: 1 }) + // 10. PoW on the pubkey: a ground id from a key with no leading zero bit. + await send(['EVENT', grind({ kind: 1 })], { expect: 1 }) + // 11. The kind blacklist, once both PoW gates pass. + await send(['EVENT', grind({ kind: 7, tags: [['e', '0'.repeat(64)]] })], { expect: 1 }) + // 12. A protected event from somebody other than its author. + await send(['EVENT', grind({ kind: 1, tags: [['-']] })], { expect: 1 }) + // 13. NIP-43 membership: a grounded event from a pubkey that is not admitted. + await send(['EVENT', grind({ kind: 1, tags: [['p', CLIENT_PUBKEY]] })], { expect: 1 }) + + }, + }) +} + +// The stream restarts here: module loading draws nothing this fixture wants to replay, and the +// replay's own generator starts at the seed, so the first session's challenge is the same on +// both sides. +rngState = BigInt(SEED) + +// The capture's settings loader reads the environment as it loads, so the relay key is +// installed here, after every module is in memory and before the first session. +process.env.SECRET = RELAY_SECRET +process.env.RELAY_PRIVATE_KEY = RELAY_SECRET +console.error(`secret ${process.env.SECRET ? 'set' : 'unset'}`) + +await runAll() + +// ---- output --------------------------------------------------------------------------- +const outDirFlag = process.argv.indexOf('--out-dir') +const outDir = + outDirFlag === -1 + ? path.resolve(path.dirname(new URL(import.meta.url).pathname), '../fixtures') + : path.resolve(process.argv[outDirFlag + 1]) + +const fixture = { + capture: CAPTURE, + generated_by: 'crates/nostream-relay/tests/capture/gen-wire-sessions.mjs', + clock: { start_ms: CLOCK_START_MS }, + seed: SEED, + client_secret: CLIENT_SECRET, + relay_secret: RELAY_SECRET, + sessions, +} + +const target = path.join(outDir, 'wire-sessions.json') +fs.writeFileSync(target, `${JSON.stringify(fixture, null, 2)}\n`) +console.log(`${sessions.length} sessions -> ${target}`) + +cryptoModule.randomBytes = originalRandomBytes +process.exit(0) diff --git a/crates/nostream-relay/tests/fixtures/ts-case-map.json b/crates/nostream-relay/tests/fixtures/ts-case-map.json new file mode 100644 index 000000000..54bbec2f5 --- /dev/null +++ b/crates/nostream-relay/tests/fixtures/ts-case-map.json @@ -0,0 +1,1212 @@ +{ + "capture": "242a3001d77149e4d70151ec03060c4ef44a1547", + "generated_by": "crates/nostream-relay/tests/ts_case_parity.rs reads this map; a rule table over the census produced it, reviewed case by case", + "cases": 557, + "groups": [ + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::an_admitted_pubkey_publishes_and_a_low_balance_blocks", + "titles": [ + "EventMessageHandler handleMessage rejects event if it can't be accepted", + "EventMessageHandler handleMessage rejects event if user is not admitted", + "EventMessageHandler isUserAdmitted NIP-43 membership fulfills with restricted reason if user exists but is not admitted", + "EventMessageHandler isUserAdmitted NIP-43 membership fulfills with restricted reason if user is not admitted and payments are disabled", + "EventMessageHandler isUserAdmitted NIP-43 membership fulfills with undefined for a join request from a non-member", + "EventMessageHandler isUserAdmitted NIP-43 membership fulfills with undefined for a leave request from a non-member", + "EventMessageHandler isUserAdmitted NIP-43 membership fulfills with undefined for an invite request from a non-member", + "EventMessageHandler isUserAdmitted NIP-43 membership fulfills with undefined if user is admitted", + "EventMessageHandler isUserAdmitted NIP-43 membership skips the minimum balance check when payments are disabled", + "EventMessageHandler isUserAdmitted fulfills with reason if user does not meet minimum balance", + "EventMessageHandler isUserAdmitted fulfills with reason if user is not admitted", + "EventMessageHandler isUserAdmitted fulfills with reason if user is not admitted", + "EventMessageHandler isUserAdmitted fulfills with reason if user is not found", + "EventMessageHandler isUserAdmitted fulfills with undefined if event pubkey equals relay's own public key", + "EventMessageHandler isUserAdmitted fulfills with undefined if user is admitted" + ] + }, + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::the_event_rate_limit_answers_with_the_backoff", + "titles": [ + "EventMessageHandler handleMessage rejects event if rate-limited with the backoff in ms", + "EventMessageHandler isRateLimited calls hit with given rate limit settings", + "EventMessageHandler isRateLimited fulfills with false if event limits setting is not set", + "EventMessageHandler isRateLimited fulfills with false if limits setting is not set", + "EventMessageHandler isRateLimited fulfills with false if not rate limited", + "EventMessageHandler isRateLimited fulfills with false if rate limits setting is empty", + "EventMessageHandler isRateLimited fulfills with false if rate limits setting is not set", + "EventMessageHandler isRateLimited fulfills with the tripped period if rate limited by second rate limit setting", + "EventMessageHandler isRateLimited stops hitting subsequent rate limit windows once one is exceeded" + ] + }, + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/limits.rs::a_failing_event_limiter_limits_the_event_without_a_backoff", + "titles": [ + "EventMessageHandler handleMessage rejects event if rate-limited without a backoff when none is known", + "EventMessageHandler isRateLimited fails closed when the rate limiter backend is unavailable" + ] + }, + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::a_vanished_pubkey_is_blocked", + "titles": [ + "EventMessageHandler handleMessage rejects event if request to vanish is active for pubkey", + "EventMessageHandler isBlockedByRequestToVanish returns undefined for request to vanish events", + "EventMessageHandler isBlockedByRequestToVanish returns undefined if event pubkey equals relay's own public key" + ] + }, + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::event_id_mismatch_is_rejected", + "titles": [ + "EventMessageHandler handleMessage rejects event if invalid", + "EventMessageHandler isEventValid returns reason if event id is not valid", + "EventMessageHandler isEventValid returns undefined if event is valid" + ] + }, + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::nip05_gates_publication", + "titles": [ + "EventMessageHandler checkNip05Verification allows author when lastVerifiedAt is recent even if isVerified is false (transient re-check failure)", + "EventMessageHandler checkNip05Verification allows author when lastVerifiedAt is recent even if isVerified is false (transient re-check failure)", + "EventMessageHandler checkNip05Verification returns reason if domain is blacklisted", + "EventMessageHandler checkNip05Verification returns reason if domain is blacklisted", + "EventMessageHandler checkNip05Verification returns reason if domain is not in whitelist", + "EventMessageHandler checkNip05Verification returns reason if domain is not in whitelist", + "EventMessageHandler checkNip05Verification returns reason if no verification found for pubkey", + "EventMessageHandler checkNip05Verification returns reason if no verification found for pubkey", + "EventMessageHandler checkNip05Verification returns reason if verification exists but has no lastVerifiedAt", + "EventMessageHandler checkNip05Verification returns reason if verification exists but has no lastVerifiedAt", + "EventMessageHandler checkNip05Verification returns reason if verification is expired", + "EventMessageHandler checkNip05Verification returns reason if verification is expired", + "EventMessageHandler checkNip05Verification returns undefined for kind 0 events (SET_METADATA)", + "EventMessageHandler checkNip05Verification returns undefined for kind 0 events (SET_METADATA)", + "EventMessageHandler checkNip05Verification returns undefined if domain is in whitelist", + "EventMessageHandler checkNip05Verification returns undefined if domain is in whitelist", + "EventMessageHandler checkNip05Verification returns undefined if event pubkey equals relay public key", + "EventMessageHandler checkNip05Verification returns undefined if event pubkey equals relay public key", + "EventMessageHandler checkNip05Verification returns undefined if nip05 mode is disabled", + "EventMessageHandler checkNip05Verification returns undefined if nip05 mode is disabled", + "EventMessageHandler checkNip05Verification returns undefined if nip05 mode is passive", + "EventMessageHandler checkNip05Verification returns undefined if nip05 mode is passive", + "EventMessageHandler checkNip05Verification returns undefined if nip05 settings are not set", + "EventMessageHandler checkNip05Verification returns undefined if nip05 settings are not set", + "EventMessageHandler checkNip05Verification returns undefined if verification is valid and not expired", + "EventMessageHandler checkNip05Verification returns undefined if verification is valid and not expired", + "EventMessageHandler checkNip05Verification treats isVerified=true with null lastVerifiedAt as unverified (historical/bad data)", + "EventMessageHandler checkNip05Verification treats isVerified=true with null lastVerifiedAt as unverified (historical/bad data)", + "EventMessageHandler handleMessage rejects event if NIP-05 verification is required" + ] + }, + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::an_expired_event_is_rejected", + "titles": [ + "EventMessageHandler handleMessage rejects event if it is expired" + ] + }, + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::the_default_strategy_answers_ok_and_duplicate", + "titles": [ + "EventMessageHandler handleMessage calls strategy with event", + "EventMessageHandler handleMessage does not call strategy if none given", + "EventMessageHandler handleMessage does not reject if strategy rejects" + ] + }, + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "test": "crates/nostream-core/src/adaptive_pow.rs::the_sample_decays_towards_the_target_when_events_stop", + "titles": [ + "EventMessageHandler handleMessage adaptive pow load recording does not record load for an event rejected before acceptance (canAcceptEvent)", + "EventMessageHandler handleMessage adaptive pow load recording does not record load for an event rejected by rate limiting", + "EventMessageHandler handleMessage adaptive pow load recording records load only once the event clears every check and is dispatched" + ] + }, + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::created_at_deltas_are_enforced_in_both_directions", + "titles": [ + "EventMessageHandler canAcceptEvent content maxNegativeDelta returns reason if createdDate is too far in the past", + "EventMessageHandler canAcceptEvent content maxNegativeDelta returns undefined if maxNegativeDelta is zero", + "EventMessageHandler canAcceptEvent createdAt maxNegativeDelta returns reason if createdDate is too far in the past", + "EventMessageHandler canAcceptEvent createdAt maxNegativeDelta returns undefined if maxNegativeDelta is zero", + "EventMessageHandler canAcceptEvent createdAt maxPositiveDelta returns reason if createdDate is too far in the future", + "EventMessageHandler canAcceptEvent createdAt maxPositiveDelta returns undefined if maxPositiveDelta is zero", + "EventMessageHandler canAcceptEvent createdAt returns undefined if event pubkey equals relay public key" + ] + }, + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::the_content_limit_is_bytes_of_utf16", + "titles": [ + "EventMessageHandler canAcceptEvent content maxLength (deprecated) returns reason if content is too long", + "EventMessageHandler canAcceptEvent content maxLength (deprecated) returns reason if content is too long", + "EventMessageHandler canAcceptEvent content maxLength (deprecated) returns undefined if content is not set", + "EventMessageHandler canAcceptEvent content maxLength (deprecated) returns undefined if content is short", + "EventMessageHandler canAcceptEvent content maxLength (deprecated) returns undefined if kind does not match and content is too long", + "EventMessageHandler canAcceptEvent content maxLength (deprecated) returns undefined if kind matches and content is short", + "EventMessageHandler canAcceptEvent content maxLength (deprecated) returns undefined if maxLength is zero", + "EventMessageHandler canAcceptEvent content maxLength returns reason if content is too long", + "EventMessageHandler canAcceptEvent content maxLength returns reason if kind matches but content is too long", + "EventMessageHandler canAcceptEvent content maxLength returns undefined if kind does not match", + "EventMessageHandler canAcceptEvent content maxLength returns undefined if kind matches but content is short", + "EventMessageHandler canAcceptEvent content maxLength returns undefined if maxLength is disabled", + "EventMessageHandler canAcceptEvent content maxLength returns undefned if content is not too long" + ] + }, + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::the_static_proof_of_work_requirements_are_enforced", + "titles": [ + "EventMessageHandler canAcceptEvent eventId minLeadingZeroBits returns reason if eventId has insufficient proof of work ", + "EventMessageHandler canAcceptEvent eventId minLeadingZeroBits returns undefined if eventId has sufficient proof of work ", + "EventMessageHandler canAcceptEvent eventId minLeadingZeroBits returns undefined if minLeadingZeroBits is zero", + "EventMessageHandler canAcceptEvent pubkey minLeadingZeroBits returns reason if pubkey has insufficient proof of work ", + "EventMessageHandler canAcceptEvent pubkey minLeadingZeroBits returns undefined if minLeadingZeroBits is zero", + "EventMessageHandler canAcceptEvent pubkey minLeadingZeroBits returns undefined if pubkey has sufficient proof of work " + ] + }, + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "test": "crates/nostream-core/src/adaptive_pow.rs::the_ratio_scales_between_the_floor_and_the_ceiling", + "titles": [ + "EventMessageHandler canAcceptEvent pubkey pow (adaptive) does not apply the adaptive difficulty to the pubkey check", + "EventMessageHandler canAcceptEvent pubkey pow (adaptive) does not record load itself -- repeated calls do not change the observed rate", + "EventMessageHandler canAcceptEvent pubkey pow (adaptive) ignores the static eventId.minLeadingZeroBits setting while adaptive pow is enabled", + "EventMessageHandler canAcceptEvent pubkey pow (adaptive) rejects with the floor difficulty when insufficient and under target", + "EventMessageHandler canAcceptEvent pubkey pow (adaptive) scales the required difficulty up as the sustained recorded rate exceeds target", + "EventMessageHandler canAcceptEvent pubkey pow (adaptive) still enforces the static pubkey.minLeadingZeroBits setting while adaptive pow is enabled", + "EventMessageHandler canAcceptEvent pubkey pow (adaptive) uses the floor difficulty while the observed rate is at or under target" + ] + }, + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "test": "crates/nostream-core/src/adaptive_pow.rs::the_wot_policy_takes_the_closest_eligible_threshold", + "titles": [ + "EventMessageHandler canAcceptEvent pubkey pow (adaptive) wotThresholds applies a partial reduction at distance 2 (half of the floor of 16 is 8)", + "EventMessageHandler canAcceptEvent pubkey pow (adaptive) wotThresholds bypasses eventId PoW entirely for a direct follow (distance 1)", + "EventMessageHandler canAcceptEvent pubkey pow (adaptive) wotThresholds does not consult the WoT graph when no thresholds are configured", + "EventMessageHandler canAcceptEvent pubkey pow (adaptive) wotThresholds rejects at distance 2 when the event does not meet even the reduced requirement", + "EventMessageHandler canAcceptEvent pubkey pow (adaptive) wotThresholds requires the full computed difficulty beyond every threshold", + "EventMessageHandler canAcceptEvent pubkey pow (adaptive) wotThresholds requires the full computed difficulty for a pubkey outside the trust graph" + ] + }, + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::pubkey_whitelist_and_blacklist_block", + "titles": [ + "EventMessageHandler canAcceptEvent pubkey blacklist returns reason if pubkey is blacklisted", + "EventMessageHandler canAcceptEvent pubkey blacklist returns undefined if blacklist is empty", + "EventMessageHandler canAcceptEvent pubkey blacklist returns undefined if pubkey extends a blacklist entry but is not an exact match", + "EventMessageHandler canAcceptEvent pubkey blacklist returns undefined if pubkey is not an exact match in the blacklist", + "EventMessageHandler canAcceptEvent pubkey blacklist returns undefined if pubkey is not blacklisted", + "EventMessageHandler canAcceptEvent pubkey whitelist returns reason if pubkey is not an exact match in the whitelist", + "EventMessageHandler canAcceptEvent pubkey whitelist returns reason if pubkey is not whitelisted", + "EventMessageHandler canAcceptEvent pubkey whitelist returns reason if pubkey is not whitelisted by exact match", + "EventMessageHandler canAcceptEvent pubkey whitelist returns undefined if pubkey is whitelisted", + "EventMessageHandler canAcceptEvent pubkey whitelist returns undefined if whitelist is empty" + ] + }, + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::kind_whitelist_and_blacklist_block", + "titles": [ + "EventMessageHandler canAcceptEvent kind blacklist returns reason if kind is blacklisted in range", + "EventMessageHandler canAcceptEvent kind blacklist returns undefined if blacklist is empty", + "EventMessageHandler canAcceptEvent kind blacklist returns undefined if kind is not blacklisted", + "EventMessageHandler canAcceptEvent kind blacklist returns undefined if kind is not blacklisted in range", + "EventMessageHandler canAcceptEvent kind whitelist returns reason if kind is blacklisted and whitelisted", + "EventMessageHandler canAcceptEvent kind whitelist returns reason if kind is blacklisted and whitelisted in range", + "EventMessageHandler canAcceptEvent kind whitelist returns reason if kind is not whitelisted", + "EventMessageHandler canAcceptEvent kind whitelist returns reason if kind is not whitelisted in range", + "EventMessageHandler canAcceptEvent kind whitelist returns undefined if kind is whitelisted", + "EventMessageHandler canAcceptEvent kind whitelist returns undefined if kind is whitelisted in range", + "EventMessageHandler canAcceptEvent kind whitelist returns undefined if whitelist is empty" + ] + }, + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::request_to_vanish_relay_tag_is_checked", + "titles": [ + "EventMessageHandler isEventValid returns reason if request to vanish relay tag does not match relay URL" + ] + }, + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::event_signature_mismatch_is_rejected", + "titles": [ + "EventMessageHandler isEventValid returns reason if event signature is not valid" + ] + }, + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::inner_kinds_must_be_gift_wrapped", + "titles": [ + "EventMessageHandler isEventValid NIP-17 inner event blocking blocks kind 13 (Seal) with a clear rejection message", + "EventMessageHandler isEventValid NIP-17 inner event blocking blocks kind 14 (Direct Message) with a clear rejection message", + "EventMessageHandler isEventValid NIP-17 inner event blocking blocks kind 15 (File Message) with a clear rejection message", + "EventMessageHandler isEventValid NIP-17 inner event blocking blocks kind 444 (Marmot Welcome rumor) with a clear rejection message", + "EventMessageHandler isEventValid NIP-17 inner event blocking does not block a kind 1059 (Gift Wrap) event", + "EventMessageHandler isEventValid NIP-17 inner event blocking does not block a kind 445 (Marmot Group Event)", + "EventMessageHandler isEventValid NIP-17 inner event blocking does not block a regular kind 1 event" + ] + }, + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::the_event_rate_limit_skips_a_non_matching_kind_and_the_relay_pubkey", + "titles": [ + "EventMessageHandler isRateLimited calls rate limiter if IP is not whitelisted", + "EventMessageHandler isRateLimited calls rate limiter if pubkey is not whitelisted", + "EventMessageHandler isRateLimited fulfills with false if event pubkey equals relay's own public key", + "EventMessageHandler isRateLimited skips rate limiter if IP is whitelisted", + "EventMessageHandler isRateLimited skips rate limiter if pubkey is whitelisted" + ] + }, + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "exempt": "admission fee schedules and their waivers are not ported; job 010 owns the payments surface — job 008", + "titles": [ + "EventMessageHandler isUserAdmitted fulfills with undefined if admission fee schedule is waived for event kind", + "EventMessageHandler isUserAdmitted fulfills with undefined if admission fee schedule is waived for event kind range", + "EventMessageHandler isUserAdmitted fulfills with undefined if admission fee schedule is waived for pubkey", + "EventMessageHandler isUserAdmitted fulfills with undefined if admission fee schedules are not set", + "EventMessageHandler isUserAdmitted fulfills with undefined if fee schedules are not set", + "EventMessageHandler isUserAdmitted fulfills with undefined if payments are disabled", + "EventMessageHandler isUserAdmitted fulfills with undefined if there are no admission fee schedules", + "EventMessageHandler isUserAdmitted fulfills with undefined if there are no enabled admission fee schedules" + ] + }, + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "exempt": "admission fee schedules are not ported; job 010 owns the payments surface — job 008", + "titles": [ + "EventMessageHandler isUserAdmitted fulfills with reason if admission fee schedule is not waived for event kind range" + ] + }, + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "exempt": "the admission cache and its 300s/60s TTLs are not ported; job 010 owns the payments surface — job 008", + "titles": [ + "EventMessageHandler isUserAdmitted caching caches admitted status with 300s ttl when user is admitted and has balance", + "EventMessageHandler isUserAdmitted caching caches blocked status with 60s ttl when user is not found", + "EventMessageHandler isUserAdmitted caching caches insufficient balance status with 60s ttl when user balance is too low", + "EventMessageHandler isUserAdmitted caching falls back to repository lookup when cache read fails", + "EventMessageHandler isUserAdmitted caching fulfills with reason and uses cache hit for blocked user without hitting DB", + "EventMessageHandler isUserAdmitted caching fulfills with reason and uses cache hit for insufficient balance without hitting DB", + "EventMessageHandler isUserAdmitted caching fulfills with undefined and uses cache hit for admitted user without hitting DB" + ] + }, + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "test": "crates/nostream-core/src/expiration.rs::expired_event_is_true_past_its_expiration", + "titles": [ + "EventMessageHandler addExpirationMetadata adds expiration metadata when expiration tag is present" + ] + }, + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "exempt": "processNip05Metadata (the NIP-05 metadata write) is not run by this port; job 010 owns it — job 008", + "titles": [ + "EventMessageHandler processNip05Metadata deletes verification when kind-0 has no nip05 in content", + "EventMessageHandler processNip05Metadata deletes verification when kind-0 has no nip05 in content", + "EventMessageHandler processNip05Metadata does nothing for non-kind-0 events", + "EventMessageHandler processNip05Metadata does nothing for non-kind-0 events", + "EventMessageHandler processNip05Metadata does nothing when domain is not allowed", + "EventMessageHandler processNip05Metadata does nothing when domain is not allowed", + "EventMessageHandler processNip05Metadata does nothing when nip05 identifier is unparseable", + "EventMessageHandler processNip05Metadata does nothing when nip05 identifier is unparseable", + "EventMessageHandler processNip05Metadata does nothing when nip05 mode is disabled", + "EventMessageHandler processNip05Metadata does nothing when nip05 mode is disabled", + "EventMessageHandler processNip05Metadata does nothing when nip05 settings are undefined", + "EventMessageHandler processNip05Metadata does nothing when nip05 settings are undefined", + "EventMessageHandler processNip05Metadata handles verification errors gracefully (thrown by verifier)", + "EventMessageHandler processNip05Metadata handles verification errors gracefully (thrown by verifier)", + "EventMessageHandler processNip05Metadata ignores delete errors when kind-0 has no nip05 in content", + "EventMessageHandler processNip05Metadata increments failureCount from existing row on definitive mismatch", + "EventMessageHandler processNip05Metadata increments failureCount from existing row on definitive mismatch", + "EventMessageHandler processNip05Metadata preserves prior isVerified/lastVerifiedAt on transient error", + "EventMessageHandler processNip05Metadata preserves prior isVerified/lastVerifiedAt on transient error", + "EventMessageHandler processNip05Metadata upserts with unverified state and nulls lastVerifiedAt on definitive mismatch", + "EventMessageHandler processNip05Metadata upserts with unverified state and nulls lastVerifiedAt on definitive mismatch", + "EventMessageHandler processNip05Metadata verifies and upserts on successful verification", + "EventMessageHandler processNip05Metadata verifies and upserts on successful verification", + "EventMessageHandler processNip05Metadata works correctly in passive mode", + "EventMessageHandler processNip05Metadata works correctly in passive mode" + ] + }, + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::authentication_can_be_required_to_publish", + "titles": [ + "EventMessageHandler isAuthenticationRequired returns auth-required when enabled and the author is not authenticated", + "EventMessageHandler isAuthenticationRequired returns undefined when authRequired is disabled", + "EventMessageHandler isAuthenticationRequired returns undefined when enabled and the author is authenticated" + ] + }, + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::a_repost_may_not_embed_a_protected_event", + "titles": [ + "EventMessageHandler isProtectedEventBlocked rejects on the outer protected tag before checking embedded repost content", + "EventMessageHandler isProtectedEventBlocked returns reason if event has a protected tag and user is not authenticated", + "EventMessageHandler isProtectedEventBlocked returns reason if kind 6 repost embeds a repost that embeds a valid protected event (nested)", + "EventMessageHandler isProtectedEventBlocked returns reason if kind 6 repost embeds a valid protected event", + "EventMessageHandler isProtectedEventBlocked returns undefined for non-repost event kinds with JSON content", + "EventMessageHandler isProtectedEventBlocked returns undefined if embedded event has invalid id/sig (forged protected tag)", + "EventMessageHandler isProtectedEventBlocked returns undefined if event has a protected tag and user is authenticated", + "EventMessageHandler isProtectedEventBlocked returns undefined if event has no protected tag", + "EventMessageHandler isProtectedEventBlocked returns undefined if event has no tags", + "EventMessageHandler isProtectedEventBlocked returns undefined if kind 6 repost embeds a valid non-protected event", + "EventMessageHandler isProtectedEventBlocked returns undefined if kind 6 repost has empty content", + "EventMessageHandler isProtectedEventBlocked returns undefined if kind 6 repost has invalid JSON content", + "EventMessageHandler isProtectedEventBlocked returns undefined if kind 6 repost has non-array embedded tags" + ] + }, + { + "file": "test/unit/handlers/subscribe-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::a_duplicate_req_is_refused_with_the_capture_string", + "titles": [ + "SubscribeMessageHandler #handleMessage emits notice message if subscription is rejected" + ] + }, + { + "file": "test/unit/handlers/subscribe-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::req_streams_a_stored_event_then_eose", + "titles": [ + "SubscribeMessageHandler #fetchAndSend ends event stream if error occurs", + "SubscribeMessageHandler #fetchAndSend sends EOSE", + "SubscribeMessageHandler #fetchAndSend sends event if client is subscribed", + "SubscribeMessageHandler #handleMessage emits subscribe event if subscription is accepted" + ] + }, + { + "file": "test/unit/handlers/subscribe-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::a_restricted_only_req_from_an_unauthenticated_client_is_closed", + "titles": [ + "SubscribeMessageHandler #handleMessage closes subscription with auth-required if unauthenticated client requests only restricted kinds" + ] + }, + { + "file": "test/unit/handlers/subscribe-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::a_restricted_read_delivers_only_to_the_author_or_a_p_tagged_reader", + "titles": [ + "SubscribeMessageHandler #fetchAndSend does not send restricted-kind events to unauthenticated clients", + "SubscribeMessageHandler #fetchAndSend sends restricted-kind events to the authenticated author", + "SubscribeMessageHandler #fetchAndSend sends restricted-kind events to the authenticated recipient", + "SubscribeMessageHandler #handleMessage accepts mixed-kind subscription from unauthenticated client", + "SubscribeMessageHandler #handleMessage accepts restricted-kind subscription if client is authenticated" + ] + }, + { + "file": "test/unit/handlers/subscribe-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::an_invite_request_mints_one_kind_28935_event_before_eose", + "titles": [ + "SubscribeMessageHandler #handleMessage NIP-43 invite requests boundaries mints exactly one code for two non-identical filters naming 28935", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests boundaries serves stored events alongside the claim event on a mixed filter", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests when every guard passes adds a NIP-40 expiration tag mirroring inviteCodeExpirySeconds", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests when every guard passes emits a kind 28935 event carrying the minted claim code", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests when every guard passes emits exactly one claim event followed by EOSE", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests when every guard passes mints for a pubkey on a non-empty inviteRequestWhitelist", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests when every guard passes mints when info.self matches the derived signing pubkey", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests when every guard passes never broadcasts the claim event", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests when every guard passes never persists the claim event", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests when every guard passes omits the expiration tag when codes never expire", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests when every guard passes records the requesting pubkey as createdBy", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests when every guard passes signs the event as the derived relay pubkey with a valid id and signature", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests when every guard passes tags the event as NIP-70 protected" + ] + }, + { + "file": "test/unit/handlers/subscribe-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::an_unauthenticated_invite_request_gets_eose_only", + "titles": [ + "SubscribeMessageHandler #handleMessage NIP-43 invite requests boundaries does not mint for a REQ that does not ask for kind 28935", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests boundaries does not mint for a filter with no kinds at all", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests boundaries still sends EOSE when the invite code repository throws", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests boundaries still sends EOSE when the relay private key cannot be derived", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests guards does not consult the rate limiter when no invite rate limits are configured", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests guards fails closed when the rate limiter is unavailable", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests guards skips when NIP-43 is disabled", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests guards skips when a non-empty inviteRequestWhitelist does not include the requester", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests guards skips when allowInviteRequests is not enabled", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests guards skips when info.self does not match the derived signing pubkey", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests guards skips when info.self is a malformed npub", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests guards skips when the nip43 block is absent entirely", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests guards skips when the per-pubkey rate limit is exceeded", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests guards skips when the requester is not NIP-42 authenticated" + ] + }, + { + "file": "test/unit/handlers/subscribe-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::a_close_aborts_an_in_flight_read_without_a_frame", + "titles": [ + "SubscribeMessageHandler #fetchAndSend aborts and destroys the event stream when abort() is called", + "SubscribeMessageHandler #fetchAndSend destroys event stream if aborted", + "SubscribeMessageHandler #fetchAndSend does not send event if client is not subscribed to it", + "SubscribeMessageHandler #fetchAndSend does not send expired events", + "SubscribeMessageHandler #fetchAndSend sends event if expiration is in the future" + ] + }, + { + "file": "test/unit/handlers/subscribe-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/connection.rs::matching_subscription_ids_are_returned_in_id_order", + "titles": [ + "SubscribeMessageHandler .isClientSubscribedToEvent returns false if event matches no filter", + "SubscribeMessageHandler .isClientSubscribedToEvent returns true if event matches filter" + ] + }, + { + "file": "test/unit/handlers/subscribe-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::the_subscription_count_limit_is_the_capture_string", + "titles": [ + "SubscribeMessageHandler #canSubscribe returns reason if a filter exceeds the value count limit", + "SubscribeMessageHandler #canSubscribe returns reason if client is sending a duplicate subscription", + "SubscribeMessageHandler #canSubscribe returns reason if client subscriptions exceed limits", + "SubscribeMessageHandler #canSubscribe returns reason if filter count exceeds limit", + "SubscribeMessageHandler #canSubscribe returns reason if filter limit exceeds max limit", + "SubscribeMessageHandler #canSubscribe returns reason if subscription id is too long", + "SubscribeMessageHandler #canSubscribe returns undefined if a filter is at the value count limit, ignoring scalar criteria", + "SubscribeMessageHandler #canSubscribe returns undefined if filter value count limit is disabled", + "SubscribeMessageHandler #canSubscribe returns undefined if filters limit is disabled", + "SubscribeMessageHandler #canSubscribe returns undefined if max subscription limit is disabled", + "SubscribeMessageHandler #canSubscribe returns undefined if subscription & filter count are allowed", + "SubscribeMessageHandler #canSubscribe returns undefined if subscription id matches max length" + ] + }, + { + "file": "test/unit/handlers/subscribe-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::the_filter_limits_carry_the_captures_strings_and_typos", + "titles": [ + "SubscribeMessageHandler #canSubscribe counts filter values across every array criterion of a filter" + ] + }, + { + "file": "test/unit/handlers/count-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::count_answers_with_the_count_object", + "titles": [ + "CountMessageHandler handleMessage() drops duplicate filters before querying the repository", + "CountMessageHandler handleMessage() returns COUNT with the result when counting works" + ] + }, + { + "file": "test/unit/handlers/count-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::count_limits_are_the_capture_strings", + "titles": [ + "CountMessageHandler handleMessage() returns CLOSED when a filter has too many values", + "CountMessageHandler handleMessage() returns CLOSED when the query ID is too long", + "CountMessageHandler handleMessage() returns CLOSED when the request has too many filters" + ] + }, + { + "file": "test/unit/handlers/count-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::a_count_repository_failure_is_generic", + "titles": [ + "CountMessageHandler handleMessage() returns CLOSED when counting fails in the repository" + ] + }, + { + "file": "test/unit/handlers/count-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::a_restricted_count_needs_a_scoped_authors_filter", + "titles": [ + "CountMessageHandler handleMessage() returns CLOSED when counting restricted kinds scoped to another pubkey", + "CountMessageHandler handleMessage() returns CLOSED with auth-required when counting restricted kinds unauthenticated", + "CountMessageHandler handleMessage() returns COUNT when restricted kinds are scoped to the authenticated client" + ] + }, + { + "file": "test/unit/handlers/count-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::count_can_be_disabled_by_configuration", + "titles": [ + "CountMessageHandler handleMessage() returns CLOSED when COUNT is disabled in settings" + ] + }, + { + "file": "test/unit/handlers/auth-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::auth_outcomes_are_the_captures_seven", + "titles": [ + "AuthMessageHandler handleMessage() accepts relay URL with different path but same domain", + "AuthMessageHandler handleMessage() accepts timestamp exactly at the 10-minute boundary", + "AuthMessageHandler handleMessage() authenticates successfully with a valid AUTH event", + "AuthMessageHandler handleMessage() rejects when challenge tag does not match", + "AuthMessageHandler handleMessage() rejects when created_at is too far in the future", + "AuthMessageHandler handleMessage() rejects when created_at is too far in the past", + "AuthMessageHandler handleMessage() rejects when event ID does not match", + "AuthMessageHandler handleMessage() rejects when kind is not 22242", + "AuthMessageHandler handleMessage() rejects when relay tag does not match", + "AuthMessageHandler handleMessage() rejects when relay tag has invalid URL", + "AuthMessageHandler handleMessage() rejects when signature is invalid" + ] + }, + { + "file": "test/unit/handlers/auth-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::the_challenge_is_never_rotated_by_auth", + "titles": [ + "AuthMessageHandler handleMessage() rejects when the AUTH event id was already accepted on this socket" + ] + }, + { + "file": "test/unit/handlers/unsubscribe-message-handler.spec.ts", + "test": "crates/nostream-relay/tests/connection.rs::unsubscribing_removes_the_entry_and_reports_it", + "titles": [ + "UnsubscribeMessageHandler handleMessage() emits unsubscribe event with subscription Id" + ] + }, + { + "file": "test/unit/handlers/event-strategies/default-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::the_default_strategy_answers_ok_and_duplicate", + "titles": [ + "DefaultEventStrategy execute broadcast event if event is created", + "DefaultEventStrategy execute creates event", + "DefaultEventStrategy execute does not broadcast event if event is duplicate" + ] + }, + { + "file": "test/unit/handlers/event-strategies/default-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::a_failed_insert_answers_with_the_generic_error", + "titles": [ + "DefaultEventStrategy execute rejects if unable to create event" + ] + }, + { + "file": "test/unit/adapters/web-socket-adapter.spec.ts", + "test": "crates/nostream-relay/tests/transport.rs::the_payload_limit_defaults_to_131072_and_follows_the_settings", + "titles": [ + "WebSocketAdapter IPv6 support handles IPv6 client address", + "WebSocketAdapter constructor extracts clientId from sec-websocket-key header", + "WebSocketAdapter constructor resolves client address from request", + "WebSocketAdapter getClientAddress returns the client IP address", + "WebSocketAdapter getClientId returns the client ID" + ] + }, + { + "file": "test/unit/adapters/web-socket-adapter.spec.ts", + "test": "crates/nostream-relay/tests/connection.rs::two_overlapping_subscriptions_both_receive_a_live_event", + "titles": [ + "WebSocketAdapter constructor registers WebSocket event listeners", + "WebSocketAdapter constructor registers internal event listeners", + "WebSocketAdapter onBroadcast emits broadcast event on the WebSocket server adapter", + "WebSocketAdapter onClientMessage sets alive to true when message is received", + "WebSocketAdapter onClientPong marks client as alive", + "WebSocketAdapter onSendEvent (via Event event) NIP-56: hidden content still broadcasts a hidden-target event when hideActionableReports is disabled", + "WebSocketAdapter onSendEvent (via Event event) NIP-56: hidden content still broadcasts an unrelated event when hideActionableReports is enabled", + "WebSocketAdapter onSendEvent (via Event event) does not send event not matching any filter", + "WebSocketAdapter onSendEvent (via Event event) does not send restricted-kind event to a client authenticated as somebody else", + "WebSocketAdapter onSendEvent (via Event event) does not send restricted-kind event to unauthenticated client", + "WebSocketAdapter onSendEvent (via Event event) sends event matching subscription filters", + "WebSocketAdapter onSendEvent (via Event event) sends restricted-kind event to the authenticated author", + "WebSocketAdapter onSendEvent (via Event event) sends restricted-kind event to the authenticated recipient", + "WebSocketAdapter onSendEvent edge cases does not send when socket is not OPEN", + "WebSocketAdapter onSendEvent edge cases sends event to multiple matching subscriptions", + "WebSocketAdapter sendMessage (via Message event) does nothing when WebSocket is not OPEN", + "WebSocketAdapter sendMessage (via Message event) sends JSON-serialized message when WebSocket is OPEN" + ] + }, + { + "file": "test/unit/adapters/web-socket-adapter.spec.ts", + "test": "crates/nostream-relay/tests/connection.rs::a_subscription_registers_once_and_an_identical_re_req_is_recognised", + "titles": [ + "WebSocketAdapter getSubscriptions edge cases returns a copy that does not affect internal state", + "WebSocketAdapter getSubscriptions returns a copy of subscriptions map", + "WebSocketAdapter getSubscriptions returns an empty map when no subscriptions", + "WebSocketAdapter onClientClose clears all subscriptions when client disconnects", + "WebSocketAdapter onClientClose removes all listeners from client", + "WebSocketAdapter onSubscribed adds subscription to the map", + "WebSocketAdapter onSubscribed overwrites existing subscription with same id", + "WebSocketAdapter onUnsubscribed does not throw when removing non-existent subscription", + "WebSocketAdapter onUnsubscribed removes subscription from the map" + ] + }, + { + "file": "test/unit/adapters/web-socket-adapter.spec.ts", + "test": "crates/nostream-relay/tests/connection.rs::a_read_is_in_flight_until_it_ends_and_an_abort_notifies_it", + "titles": [ + "WebSocketAdapter onHeartbeat pings the client and sets alive to false" + ] + }, + { + "file": "test/unit/adapters/web-socket-adapter.spec.ts", + "test": "crates/nostream-relay/tests/transport.rs::a_client_that_never_pongs_is_closed_without_a_close_frame", + "titles": [ + "WebSocketAdapter onHeartbeat closes connection when client is not alive and has no subscriptions", + "WebSocketAdapter onHeartbeat closes when client is not alive even if it has active subscriptions" + ] + }, + { + "file": "test/unit/adapters/web-socket-adapter.spec.ts", + "test": "crates/nostream-relay/tests/hidden_content.rs::a_mark_hides_the_target_immediately", + "titles": [ + "WebSocketAdapter onSendEvent (via Event event) NIP-56: hidden content does not broadcast an event matching an actionable report when hideActionableReports is enabled" + ] + }, + { + "file": "test/unit/adapters/web-socket-adapter.spec.ts", + "test": "crates/nostream-relay/tests/transport.rs::a_client_ping_is_answered_with_the_same_payload", + "titles": [ + "WebSocketAdapter onClientPing responds with pong" + ] + }, + { + "file": "test/unit/adapters/web-socket-adapter.spec.ts", + "test": "crates/nostream-relay/tests/transport.rs::an_oversized_message_is_closed_with_1009", + "titles": [ + "WebSocketAdapter error handling closes client on RSV1 compression error", + "WebSocketAdapter error handling closes client on RangeError with max payload exceeded" + ] + }, + { + "file": "test/unit/adapters/web-socket-adapter.spec.ts", + "test": "crates/nostream-relay/tests/transport.rs::a_rsv_bit_with_no_negotiated_extension_is_closed_with_1002", + "titles": [ + "WebSocketAdapter error handling closes client on generic errors" + ] + }, + { + "file": "test/unit/adapters/web-socket-adapter.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::an_invalid_frame_gets_the_capture_notice", + "titles": [ + "WebSocketAdapter onClientMessage handles invalid JSON gracefully" + ] + }, + { + "file": "test/unit/adapters/web-socket-adapter.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::the_message_limiter_closes_reads_and_notices_everything_else", + "titles": [ + "WebSocketAdapter onClientMessage does not rate limit when no rateLimits are configured", + "WebSocketAdapter onClientMessage stops hitting subsequent rate limit windows once one is exceeded", + "WebSocketAdapter onClientMessage when rate limited closes a COUNT with the tripped window as backoff hint", + "WebSocketAdapter onClientMessage when rate limited closes a REQ with the tripped window as backoff hint", + "WebSocketAdapter onClientMessage when rate limited ends an active subscription and aborts its in-flight read before CLOSED", + "WebSocketAdapter onClientMessage when rate limited falls back to the notice when the frame is not valid JSON", + "WebSocketAdapter onClientMessage when rate limited sends a rate-limited notice for non-read messages" + ] + }, + { + "file": "test/unit/adapters/web-socket-adapter.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::a_whitelisted_ip_bypasses_the_message_limiter", + "titles": [ + "WebSocketAdapter onClientMessage does not rate limit when client IP is whitelisted" + ] + }, + { + "file": "test/unit/adapters/web-socket-adapter.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::a_close_emits_nothing_for_an_unknown_subscription", + "titles": [ + "WebSocketAdapter onClientMessage handles AbortError without sending notice" + ] + }, + { + "file": "test/unit/adapters/web-socket-adapter.spec.ts", + "test": "crates/nostream-core/tests/wire_codec.rs::decode_reports_the_capture_notice_strings", + "titles": [ + "WebSocketAdapter onClientMessage returns early when no handler is found for message" + ] + }, + { + "file": "test/unit/adapters/web-socket-adapter.spec.ts", + "test": "crates/nostream-relay/tests/connection.rs::a_connection_starts_unauthenticated_with_its_challenge", + "titles": [ + "WebSocketAdapter NIP-42 authentication generates different challenges for different adapters", + "WebSocketAdapter NIP-42 authentication getChallenge returns a non-empty string", + "WebSocketAdapter NIP-42 authentication getChallenge returns consistent value for the same adapter", + "WebSocketAdapter NIP-42 authentication sends AUTH challenge message on construction" + ] + }, + { + "file": "test/unit/adapters/web-socket-adapter.spec.ts", + "test": "crates/nostream-relay/tests/connection.rs::authenticating_records_the_pubkey_in_order_and_refuses_a_replay", + "titles": [ + "WebSocketAdapter NIP-42 authentication addAuthenticatedPubkey adds a pubkey without rotating the challenge", + "WebSocketAdapter NIP-42 authentication addAuthenticatedPubkey deduplicates same pubkey", + "WebSocketAdapter NIP-42 authentication addAuthenticatedPubkey supports multiple pubkeys", + "WebSocketAdapter NIP-42 authentication expires authenticated pubkeys after sessionExpirySeconds", + "WebSocketAdapter NIP-42 authentication getAuthenticatedPubkeys returns empty set initially", + "WebSocketAdapter NIP-42 authentication rejects replayed AUTH event ids" + ] + }, + { + "file": "test/unit/adapters/web-socket-server-adapter.spec.ts", + "test": "crates/nostream-relay/tests/transport.rs::the_payload_limit_defaults_to_131072_and_follows_the_settings", + "titles": [ + "WebSocketServerAdapter constructor registers broadcast event listener on itself", + "WebSocketServerAdapter constructor registers connection event listener on webSocketServer", + "WebSocketServerAdapter constructor registers error event listener on webSocketServer" + ] + }, + { + "file": "test/unit/adapters/web-socket-server-adapter.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::req_streams_a_stored_event_then_eose", + "titles": [ + "WebSocketServerAdapter getConnectedClients counts only clients with OPEN readyState", + "WebSocketServerAdapter getConnectedClients returns 0 when no clients are connected", + "WebSocketServerAdapter onConnection creates a WebSocketAdapter for new connection" + ] + }, + { + "file": "test/unit/adapters/web-socket-server-adapter.spec.ts", + "test": "crates/nostream-relay/tests/transport.rs::draining_closes_every_subscription_then_the_socket_with_1001", + "titles": [ + "WebSocketServerAdapter close calls parent close which closes webServer", + "WebSocketServerAdapter close closes the webSocketServer after draining clients", + "WebSocketServerAdapter close drains connected WebSocket clients before closing the server", + "WebSocketServerAdapter close invokes callback after full close", + "WebSocketServerAdapter close removes all listeners from webSocketServer after close", + "WebSocketServerAdapter close terminates clients that remain open after the drain timeout" + ] + }, + { + "file": "test/unit/adapters/web-socket-server-adapter.spec.ts", + "test": "crates/nostream-relay/tests/connection.rs::two_overlapping_subscriptions_both_receive_a_live_event", + "titles": [ + "WebSocketServerAdapter onBroadcast emits event to adapters of all OPEN clients", + "WebSocketServerAdapter onBroadcast skips clients that are not in OPEN state" + ] + }, + { + "file": "test/unit/adapters/web-socket-server-adapter.spec.ts", + "test": "crates/nostream-relay/tests/transport.rs::a_client_that_answers_every_ping_survives", + "titles": [ + "WebSocketServerAdapter onHeartbeat emits heartbeat to connected adapters on the heartbeat interval" + ] + }, + { + "file": "test/unit/adapters/web-socket-server-adapter.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::the_connection_limiter_terminates_the_upgrade", + "titles": [ + "WebSocketServerAdapter onConnection terminates rate-limited connections" + ] + }, + { + "file": "test/unit/adapters/web-socket-server-adapter.spec.ts", + "test": "crates/nostream-relay/tests/transport.rs::a_connection_accepted_while_draining_is_closed_with_1001", + "titles": [ + "WebSocketServerAdapter onConnection rejects new connections while draining" + ] + }, + { + "file": "test/unit/utils/nip42.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::a_restricted_only_req_from_an_unauthenticated_client_is_closed", + "titles": [ + "nip42 getRestrictedReadKinds returns configured kinds when enabled", + "nip42 getRestrictedReadKinds returns default kinds when enabled without explicit kinds", + "nip42 getRestrictedReadKinds returns empty array when restrictedReads is disabled", + "nip42 getRestrictedReadKinds returns empty array when settings are undefined", + "nip42 isAuthRequired returns false when unset or disabled", + "nip42 isAuthRequired returns true when enabled", + "nip42 isSubscriptionAuthRequired returns false for filters without kinds", + "nip42 isSubscriptionAuthRequired returns false when any filter includes an unrestricted kind", + "nip42 isSubscriptionAuthRequired returns false when the client is authenticated", + "nip42 isSubscriptionAuthRequired returns false when the feature is disabled", + "nip42 isSubscriptionAuthRequired returns true when every filter exclusively targets restricted kinds" + ] + }, + { + "file": "test/unit/utils/nip42.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::a_restricted_read_delivers_only_to_the_author_or_a_p_tagged_reader", + "titles": [ + "nip42 createReadAuthorizationGuard allows everything when the feature is disabled", + "nip42 createReadAuthorizationGuard allows restricted kinds for the tagged recipient", + "nip42 createReadAuthorizationGuard allows unrestricted kinds without consulting authentication", + "nip42 createReadAuthorizationGuard blocks restricted kinds for unauthenticated clients", + "nip42 createReadAuthorizationGuard supports kind ranges", + "nip42 isClientAuthorizedToReadMention returns false for an unrelated authenticated pubkey", + "nip42 isClientAuthorizedToReadMention returns false when no pubkeys are authenticated", + "nip42 isClientAuthorizedToReadMention returns true for a p-tagged recipient", + "nip42 isClientAuthorizedToReadMention returns true for the event author" + ] + }, + { + "file": "test/unit/utils/nip42.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::a_restricted_count_needs_a_scoped_authors_filter", + "titles": [ + "nip42 isCountAuthorized returns false for unauthenticated restricted-kind counts", + "nip42 isCountAuthorized returns false for unscoped restricted-kind counts even when authenticated", + "nip42 isCountAuthorized returns false when scoped to somebody else", + "nip42 isCountAuthorized returns true when #p is scoped to the authenticated client", + "nip42 isCountAuthorized returns true when authors are scoped to the authenticated client", + "nip42 isCountAuthorized returns true when no filter targets restricted kinds", + "nip42 isCountAuthorized returns true when the feature is disabled" + ] + }, + { + "file": "test/unit/utils/nip42-session.spec.ts", + "test": "crates/nostream-relay/tests/connection.rs::a_connection_starts_unauthenticated_with_its_challenge", + "titles": [ + "Nip42SessionManager issues a non-empty challenge on construction" + ] + }, + { + "file": "test/unit/utils/nip42-session.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::the_challenge_is_never_rotated_by_auth", + "titles": [ + "Nip42SessionManager rotateChallenge replaces the active challenge" + ] + }, + { + "file": "test/unit/utils/nip42-session.spec.ts", + "test": "crates/nostream-relay/tests/connection.rs::authenticating_records_the_pubkey_in_order_and_refuses_a_replay", + "titles": [ + "Nip42SessionManager authenticate adds a pubkey to the session", + "Nip42SessionManager clear removes one pubkey or the whole session", + "Nip42SessionManager does not expire sessions when TTL is unset or non-positive", + "Nip42SessionManager does not extend TTL when the same AUTH event is replayed", + "Nip42SessionManager expires sessions after the configured TTL", + "Nip42SessionManager rejects replayed AUTH event ids", + "Nip42SessionManager supports multiple authenticated pubkeys" + ] + }, + { + "file": "test/unit/utils/messages.spec.ts", + "test": "crates/nostream-core/tests/wire_codec.rs::notice_frame_is_a_two_element_array", + "titles": [ + "createNotice returns a notice message" + ] + }, + { + "file": "test/unit/utils/messages.spec.ts", + "test": "crates/nostream-core/tests/wire_codec.rs::event_frame_is_the_verb_the_id_and_the_event_in_wire_order", + "titles": [ + "createOutgoingEventMessage returns an event message" + ] + }, + { + "file": "test/unit/utils/messages.spec.ts", + "test": "crates/nostream-core/tests/wire_codec.rs::eose_frame_is_the_verb_and_the_subscription", + "titles": [ + "createEndOfStoredEventsNoticeMessage returns a EOSE message" + ] + }, + { + "file": "test/unit/utils/messages.spec.ts", + "test": "crates/nostream-core/tests/wire_codec.rs::ok_frame_has_four_elements_without_params_and_five_with_them", + "titles": [ + "createCommandResult appends optional params as a fifth element", + "createCommandResult returns a command result message", + "createCommandResult returns an OK message with success=false and a rejection reason", + "createCommandResult returns an OK message with success=true and a reason" + ] + }, + { + "file": "test/unit/utils/messages.spec.ts", + "test": "crates/nostream-core/tests/wire_codec.rs::closed_frame_has_three_elements_without_params_and_four_with_them", + "titles": [ + "createClosedMessage appends optional params as a fourth element", + "createClosedMessage returns a CLOSED message without params" + ] + }, + { + "file": "test/unit/utils/messages.spec.ts", + "test": "crates/nostream-core/tests/wire_codec.rs::decode_accepts_the_five_client_kinds", + "titles": [ + "createSubscriptionMessage returns a REQ message with a single filter", + "createSubscriptionMessage returns a REQ message with multiple filters", + "createSubscriptionMessage returns a subscription message with filters" + ] + }, + { + "file": "test/unit/utils/messages.spec.ts", + "test": "crates/nostream-core/tests/wire_codec.rs::relayed_event_message_is_unused_by_this_job_but_keeps_its_shape", + "titles": [ + "createRelayedEventMessage returns an EVENT message with secret when provided", + "createRelayedEventMessage returns an EVENT message without secret when secret is missing" + ] + }, + { + "file": "test/unit/utils/filter.spec.ts", + "test": "crates/nostream-core/tests/wire_codec.rs::filter_helpers_match_the_capture", + "titles": [ + "countFilterValues counts generic tag criteria", + "countFilterValues does not count empty criteria", + "countFilterValues does not count scalar criteria", + "countFilterValues returns zero for a filter without array criteria", + "countFilterValues sums values across every array criterion", + "isGenericTagQuery returns false for #0", + "isGenericTagQuery returns false for #abc", + "isGenericTagQuery returns true for #A", + "isGenericTagQuery returns true for #a" + ] + }, + { + "file": "test/unit/utils/ewma-rate-limiter.spec.ts", + "test": "crates/nostream-relay/tests/limits.rs::a_fresh_ewma_key_starts_from_zero_at_now", + "titles": [ + "EWMARateLimiter calculateEWMA returns 1 on first request with no history", + "EWMARateLimiter hit returns false on first request" + ] + }, + { + "file": "test/unit/utils/ewma-rate-limiter.spec.ts", + "test": "crates/nostream-relay/tests/limits.rs::the_ewma_decays_by_half_each_period", + "titles": [ + "EWMARateLimiter calculateEWMA decays rate after time gap", + "EWMARateLimiter calculateEWMA increases rate on burst requests with no time gap", + "EWMARateLimiter calculateEWMA rate approaches 1 after very long inactivity" + ] + }, + { + "file": "test/unit/utils/ewma-rate-limiter.spec.ts", + "test": "crates/nostream-relay/tests/limits.rs::a_limited_ewma_hit_is_still_recorded", + "titles": [ + "EWMARateLimiter hit returns true when rate limit exceeded" + ] + }, + { + "file": "test/unit/utils/sliding-window-rate-limiter.spec.ts", + "test": "crates/nostream-relay/tests/limits.rs::the_sliding_window_allows_up_to_the_rate_and_refuses_the_next", + "titles": [ + "SlidingWindowRateLimiter returns false if not rate limited", + "SlidingWindowRateLimiter returns true if rate limited" + ] + }, + { + "file": "test/unit/utils/sliding-window-rate-limiter.spec.ts", + "test": "crates/nostream-relay/tests/limits.rs::sum_plus_step_equal_to_the_rate_is_allowed", + "titles": [ + "SlidingWindowRateLimiter robustly handles string return types from Redis" + ] + }, + { + "file": "test/unit/factories/event-strategy-factory.spec.ts", + "test": "crates/nostream-relay/src/strategies/mod.rs::selection_follows_the_capture_chain", + "titles": [ + "eventStrategyFactory returns ContactListEventStrategy given a contact_list event", + "eventStrategyFactory returns DefaultEventStrategy given a legacy Marmot KeyPackage (kind 443)", + "eventStrategyFactory returns DefaultEventStrategy given a reaction event (NIP-25)", + "eventStrategyFactory returns DefaultEventStrategy given a text_note event", + "eventStrategyFactory returns DefaultEventStrategy given an external content reaction event (NIP-25)", + "eventStrategyFactory returns DeleteEventStrategy given a delete event", + "eventStrategyFactory returns DvmJobRequestEventStrategy given a DVM job request (kind 5000-5999)", + "eventStrategyFactory returns DvmJobRequestEventStrategy given the last DVM job request kind (5999)", + "eventStrategyFactory returns EphemeralEventStrategy given an ephemeral event", + "eventStrategyFactory returns GiftWrapEventStrategy given a gift wrap event", + "eventStrategyFactory returns GroupEventStrategy given a Marmot group event (kind 445)", + "eventStrategyFactory returns InviteRequestEventStrategy given a NIP-43 invite request (kind 28935)", + "eventStrategyFactory returns JoinRequestEventStrategy given a NIP-43 join request (kind 28934)", + "eventStrategyFactory returns LeaveRequestEventStrategy given a NIP-43 leave request (kind 28936)", + "eventStrategyFactory returns ParameterizedReplaceableEventStrategy given a Marmot KeyPackage event (kind 30443)", + "eventStrategyFactory returns ParameterizedReplaceableEventStrategy given a delete event", + "eventStrategyFactory returns ParameterizedReplaceableEventStrategy given a handler information event (NIP-89, kind 31990)", + "eventStrategyFactory returns ParameterizedReplaceableEventStrategy given a handler recommendation event (NIP-89, kind 31989)", + "eventStrategyFactory returns ReplaceableEvent given a replaceable event", + "eventStrategyFactory returns ReplaceableEvent given a set_metadata event", + "eventStrategyFactory returns ReplaceableEventStrategy given a Marmot KeyPackage relay list (kind 10051)", + "eventStrategyFactory returns ReplaceableEventStrategy given a relay_list event (NIP-65)", + "eventStrategyFactory returns ReportEventStrategy given a report event (NIP-56, kind 1984)", + "eventStrategyFactory returns TimestampEventStrategy given an opentimestamps (NIP-03) event", + "eventStrategyFactory returns VanishEventStrategy given a request to vanish event" + ] + }, + { + "file": "test/unit/factories/message-handler-factory.spec.ts", + "test": "crates/nostream-core/tests/wire_codec.rs::decode_accepts_the_five_client_kinds", + "titles": [ + "messageHandlerFactory returns AuthMessageHandler when given an AUTH message", + "messageHandlerFactory returns CountMessageHandler when given a COUNT message", + "messageHandlerFactory returns EventMessageHandler when given an EVENT message", + "messageHandlerFactory returns SubscribeMessageHandler when given a REQ message", + "messageHandlerFactory returns UnsubscribeMessageHandler when given a REQ message" + ] + }, + { + "file": "test/unit/factories/message-handler-factory.spec.ts", + "test": "crates/nostream-core/tests/wire_codec.rs::decode_reports_the_capture_notice_strings", + "titles": [ + "messageHandlerFactory throws when given an invalid message" + ] + }, + { + "file": "test/unit/factories/websocket-adapter-factory.spec.ts", + "test": "crates/nostream-relay/tests/handlers.rs::req_streams_a_stored_event_then_eose", + "titles": [ + "webSocketAdapterFactory returns a WebSocketAdapter" + ] + }, + { + "file": "test/unit/schemas/event-schema.spec.ts", + "test": "crates/nostream-core/tests/wire_codec.rs::strict_event_schema_rejects_an_unknown_field", + "titles": [ + "NIP-01 validate event schema content content is required", + "NIP-01 validate event schema content content must be a string", + "NIP-01 validate event schema created_at created_at contains an invalid value", + "NIP-01 validate event schema created_at created_at is required", + "NIP-01 validate event schema created_at created_at must be a multiple of 1", + "NIP-01 validate event schema created_at created_at must be a number", + "NIP-01 validate event schema created_at created_at must be greater than or equal to 0", + "NIP-01 validate event schema id id is not allowed to be empty", + "NIP-01 validate event schema id id is required", + "NIP-01 validate event schema id id must be a string", + "NIP-01 validate event schema id id must only contain hexadecimal characters", + "NIP-01 validate event schema id id must only contain lowercase characters", + "NIP-01 validate event schema identifier identifier is not allowed to be empty", + "NIP-01 validate event schema identifier identifier must be a string", + "NIP-01 validate event schema kind kind is required", + "NIP-01 validate event schema kind kind must be a multiple of 1", + "NIP-01 validate event schema kind kind must be a number", + "NIP-01 validate event schema kind kind must be greater than or equal to 0", + "NIP-01 validate event schema pubkey pubkey is not allowed to be empty", + "NIP-01 validate event schema pubkey pubkey is required", + "NIP-01 validate event schema pubkey pubkey must be a string", + "NIP-01 validate event schema pubkey pubkey must only contain hexadecimal characters", + "NIP-01 validate event schema pubkey pubkey must only contain lowercase characters", + "NIP-01 validate event schema returns error if unknown key is provided", + "NIP-01 validate event schema returns same event if event is valid", + "NIP-01 validate event schema sig sig is not allowed to be empty", + "NIP-01 validate event schema sig sig is required", + "NIP-01 validate event schema sig sig must be a string", + "NIP-01 validate event schema sig sig must only contain hexadecimal characters", + "NIP-01 validate event schema sig sig must only contain lowercase characters", + "NIP-01 validate event schema value value must be a string" + ] + }, + { + "file": "test/unit/schemas/event-schema.spec.ts", + "test": "crates/nostream-core/tests/wire_codec.rs::strict_event_schema_checks_tag_tuples", + "titles": [ + "NIP-01 validate event schema id id length must be 64 characters long", + "NIP-01 validate event schema id id length must be 64 characters long", + "NIP-01 validate event schema pubkey pubkey length must be 64 characters long", + "NIP-01 validate event schema pubkey pubkey length must be 64 characters long", + "NIP-01 validate event schema sig sig length must be 128 characters long", + "NIP-01 validate event schema sig sig length must be 128 characters long", + "NIP-01 validate event schema tag tag must be an array", + "NIP-01 validate event schema tags tags is required", + "NIP-01 validate event schema tags tags must be an array" + ] + }, + { + "file": "test/unit/schemas/event-schema.spec.ts", + "test": "crates/nostream-core/tests/wire_codec.rs::strict_event_schema_checks_the_refinements", + "titles": [ + "NIP-12 accepts event with valid base32 geohash tag", + "NIP-12 rejects event with empty geohash", + "NIP-12 rejects event with non-base32 geohash characters", + "NIP-12 rejects event with uppercase geohash", + "NIP-14 accepts subject tag on text note events", + "NIP-25 accepts external content reaction with k and i tags", + "NIP-25 accepts reaction with e tag", + "NIP-25 rejects external content reaction missing i tag", + "NIP-25 rejects external content reaction missing k tag", + "NIP-25 rejects reaction missing e tag", + "NIP-65 accepts relay_list event with no relay tags", + "NIP-65 accepts relay_list event with valid wss relay URL", + "NIP-65 accepts relay_list event with valid wss relay URL and read marker", + "NIP-65 accepts relay_list event with valid wss relay URL and write marker", + "NIP-65 does not validate relay URL on non-relay_list events with r tags", + "NIP-65 rejects relay_list event with empty relay URL", + "NIP-65 rejects relay_list event with invalid relay URL" + ] + }, + { + "file": "test/unit/schemas/message-schema.spec.ts", + "test": "crates/nostream-core/tests/wire_codec.rs::decode_accepts_the_five_client_kinds", + "titles": [ + "NIP-01 validateSchema AUTH returns same message if valid", + "NIP-01 validateSchema CLOSE returns same message if valid", + "NIP-01 validateSchema COUNT returns same message if valid", + "NIP-01 validateSchema EVENT returns same message if valid", + "NIP-01 validateSchema REQ returns same message if valid" + ] + }, + { + "file": "test/unit/schemas/message-schema.spec.ts", + "test": "crates/nostream-core/tests/wire_codec.rs::decode_reports_the_capture_notice_strings", + "titles": [ + "NIP-01 validateSchema AUTH returns error if event is missing", + "NIP-01 validateSchema AUTH returns error if event is not an object", + "NIP-01 validateSchema AUTH returns error if event is null", + "NIP-01 validateSchema CLOSE returns error if subscription ID is missing", + "NIP-01 validateSchema COUNT returns error if filter is missing", + "NIP-01 validateSchema COUNT returns error if filter is not an object", + "NIP-01 validateSchema COUNT returns error if query ID is missing", + "NIP-01 validateSchema COUNT returns error if there are too many filters", + "NIP-01 validateSchema REQ returns error if filter is missing", + "NIP-01 validateSchema REQ returns error if filter is not an object", + "NIP-01 validateSchema REQ returns error if subscription Id is missing", + "NIP-01 validateSchema REQ returns error if there are too many filters" + ] + }, + { + "file": "test/unit/utils/hidden-content-cache.spec.ts", + "test": "crates/nostream-relay/tests/hidden_content.rs::a_mark_hides_the_target_immediately", + "titles": [ + "hidden-content-cache markActionableTarget/isHidden does not hide an unmarked event", + "hidden-content-cache markActionableTarget/isHidden hides an event whose id was marked", + "hidden-content-cache markActionableTarget/isHidden hides an event whose pubkey was marked", + "hidden-content-cache markActionableTarget/isHidden ignores a target with both fields null" + ] + }, + { + "file": "test/unit/utils/hidden-content-cache.spec.ts", + "test": "crates/nostream-relay/tests/hidden_content.rs::start_seeds_the_mirror_and_keeps_refreshing_until_dropped", + "titles": [ + "hidden-content-cache startHiddenContentCache periodically re-reads the DB and reconciles the in-memory set", + "hidden-content-cache startHiddenContentCache resumes polling within one interval after hideActionableReports is hot-enabled", + "hidden-content-cache startHiddenContentCache seeds the cache from the DB at boot when hideActionableReports is enabled", + "hidden-content-cache startHiddenContentCache stopHiddenContentCache stops further polling" + ] + }, + { + "file": "test/unit/utils/hidden-content-cache.spec.ts", + "test": "crates/nostream-relay/tests/hidden_content.rs::a_tick_with_hiding_disabled_queries_nothing", + "titles": [ + "hidden-content-cache startHiddenContentCache does not poll the DB on later ticks while hideActionableReports stays disabled", + "hidden-content-cache startHiddenContentCache does not query the DB at boot when hideActionableReports is disabled" + ] + }, + { + "file": "test/unit/utils/hidden-content-cache.spec.ts", + "test": "crates/nostream-relay/tests/hidden_content.rs::a_failing_repository_leaves_the_mirror_as_it_was", + "titles": [ + "hidden-content-cache startHiddenContentCache logs and continues if the initial DB read fails, without throwing" + ] + }, + { + "file": "test/unit/utils/hidden-content-cache.spec.ts", + "test": "crates/nostream-relay/tests/hidden_content.rs::a_snapshot_replaces_the_mirror_wholesale", + "titles": [ + "hidden-content-cache resetHiddenContentCache clears previously marked targets" + ] + }, + { + "file": "test/unit/utils/shutdown-state.spec.ts", + "test": "crates/nostream-relay/tests/transport.rs::a_connection_accepted_while_draining_is_closed_with_1001", + "titles": [ + "shutdown-state tracks draining state" + ] + }, + { + "file": "test/unit/utils/shutdown-state.spec.ts", + "test": "crates/nostream-relay/src/transport.rs::drain_timeout_resolution_matches_the_capture", + "titles": [ + "shutdown-state defaults WS drain timeout to 30s", + "shutdown-state falls back to default for invalid WS drain timeout", + "shutdown-state reads WS drain timeout from env" + ] + }, + { + "file": "test/unit/utils/shutdown-state.spec.ts", + "exempt": "the process-level shutdown deadline buffer lives in nostreamd's signal path, not the relay wire — job 008", + "titles": [ + "shutdown-state adds a buffer to the primary shutdown deadline" + ] + }, + { + "file": "test/unit/utils/validation.spec.ts", + "test": "crates/nostream-core/tests/wire_codec.rs::decode_validates_the_event_payload", + "titles": [ + "attemptValidation returns value if given value matches schema", + "validateSchema returns value property with given value if it matches schema" + ] + }, + { + "file": "test/unit/utils/validation.spec.ts", + "test": "crates/nostream-core/tests/wire_codec.rs::strict_event_schema_rejects_an_unknown_field", + "titles": [ + "attemptValidation throws error if given value does not match schema", + "validateSchema returns error property with ZodError if given value does not match schema" + ] + } + ] +} diff --git a/crates/nostream-relay/tests/fixtures/ts-cases.json b/crates/nostream-relay/tests/fixtures/ts-cases.json new file mode 100644 index 000000000..e8ff1ccb2 --- /dev/null +++ b/crates/nostream-relay/tests/fixtures/ts-cases.json @@ -0,0 +1,696 @@ +{ + "capture": "242a3001d77149e4d70151ec03060c4ef44a1547", + "generated_by": "crates/nostream-relay/tests/capture/gen-ts-cases.mjs", + "total": 557, + "files": [ + { + "file": "test/unit/handlers/event-message-handler.spec.ts", + "cases": 200, + "titles": [ + "EventMessageHandler handleMessage rejects event if it can't be accepted", + "EventMessageHandler handleMessage rejects event if rate-limited with the backoff in ms", + "EventMessageHandler handleMessage rejects event if rate-limited without a backoff when none is known", + "EventMessageHandler handleMessage rejects event if request to vanish is active for pubkey", + "EventMessageHandler handleMessage rejects event if invalid", + "EventMessageHandler handleMessage rejects event if user is not admitted", + "EventMessageHandler handleMessage rejects event if NIP-05 verification is required", + "EventMessageHandler handleMessage rejects event if it is expired", + "EventMessageHandler handleMessage does not call strategy if none given", + "EventMessageHandler handleMessage calls strategy with event", + "EventMessageHandler handleMessage does not reject if strategy rejects", + "EventMessageHandler handleMessage adaptive pow load recording does not record load for an event rejected before acceptance (canAcceptEvent)", + "EventMessageHandler handleMessage adaptive pow load recording does not record load for an event rejected by rate limiting", + "EventMessageHandler handleMessage adaptive pow load recording records load only once the event clears every check and is dispatched", + "EventMessageHandler canAcceptEvent createdAt returns undefined if event pubkey equals relay public key", + "EventMessageHandler canAcceptEvent createdAt maxPositiveDelta returns undefined if maxPositiveDelta is zero", + "EventMessageHandler canAcceptEvent createdAt maxPositiveDelta returns reason if createdDate is too far in the future", + "EventMessageHandler canAcceptEvent createdAt maxNegativeDelta returns undefined if maxNegativeDelta is zero", + "EventMessageHandler canAcceptEvent createdAt maxNegativeDelta returns reason if createdDate is too far in the past", + "EventMessageHandler canAcceptEvent content maxLength returns undefined if maxLength is disabled", + "EventMessageHandler canAcceptEvent content maxLength returns undefned if content is not too long", + "EventMessageHandler canAcceptEvent content maxLength returns undefined if kind does not match", + "EventMessageHandler canAcceptEvent content maxLength returns undefined if kind matches but content is short", + "EventMessageHandler canAcceptEvent content maxLength returns reason if kind matches but content is too long", + "EventMessageHandler canAcceptEvent content maxLength returns reason if content is too long", + "EventMessageHandler canAcceptEvent content maxLength (deprecated) returns undefined if maxLength is zero", + "EventMessageHandler canAcceptEvent content maxLength (deprecated) returns undefined if content is short", + "EventMessageHandler canAcceptEvent content maxLength (deprecated) returns reason if content is too long", + "EventMessageHandler canAcceptEvent content maxLength (deprecated) returns undefined if kind matches and content is short", + "EventMessageHandler canAcceptEvent content maxLength (deprecated) returns undefined if kind does not match and content is too long", + "EventMessageHandler canAcceptEvent content maxLength (deprecated) returns reason if content is too long", + "EventMessageHandler canAcceptEvent content maxLength (deprecated) returns undefined if content is not set", + "EventMessageHandler canAcceptEvent content maxNegativeDelta returns undefined if maxNegativeDelta is zero", + "EventMessageHandler canAcceptEvent content maxNegativeDelta returns reason if createdDate is too far in the past", + "EventMessageHandler canAcceptEvent eventId minLeadingZeroBits returns undefined if minLeadingZeroBits is zero", + "EventMessageHandler canAcceptEvent eventId minLeadingZeroBits returns undefined if eventId has sufficient proof of work ", + "EventMessageHandler canAcceptEvent eventId minLeadingZeroBits returns reason if eventId has insufficient proof of work ", + "EventMessageHandler canAcceptEvent pubkey minLeadingZeroBits returns undefined if minLeadingZeroBits is zero", + "EventMessageHandler canAcceptEvent pubkey minLeadingZeroBits returns undefined if pubkey has sufficient proof of work ", + "EventMessageHandler canAcceptEvent pubkey minLeadingZeroBits returns reason if pubkey has insufficient proof of work ", + "EventMessageHandler canAcceptEvent pubkey pow (adaptive) uses the floor difficulty while the observed rate is at or under target", + "EventMessageHandler canAcceptEvent pubkey pow (adaptive) rejects with the floor difficulty when insufficient and under target", + "EventMessageHandler canAcceptEvent pubkey pow (adaptive) does not apply the adaptive difficulty to the pubkey check", + "EventMessageHandler canAcceptEvent pubkey pow (adaptive) still enforces the static pubkey.minLeadingZeroBits setting while adaptive pow is enabled", + "EventMessageHandler canAcceptEvent pubkey pow (adaptive) scales the required difficulty up as the sustained recorded rate exceeds target", + "EventMessageHandler canAcceptEvent pubkey pow (adaptive) does not record load itself -- repeated calls do not change the observed rate", + "EventMessageHandler canAcceptEvent pubkey pow (adaptive) ignores the static eventId.minLeadingZeroBits setting while adaptive pow is enabled", + "EventMessageHandler canAcceptEvent pubkey pow (adaptive) wotThresholds does not consult the WoT graph when no thresholds are configured", + "EventMessageHandler canAcceptEvent pubkey pow (adaptive) wotThresholds bypasses eventId PoW entirely for a direct follow (distance 1)", + "EventMessageHandler canAcceptEvent pubkey pow (adaptive) wotThresholds applies a partial reduction at distance 2 (half of the floor of 16 is 8)", + "EventMessageHandler canAcceptEvent pubkey pow (adaptive) wotThresholds rejects at distance 2 when the event does not meet even the reduced requirement", + "EventMessageHandler canAcceptEvent pubkey pow (adaptive) wotThresholds requires the full computed difficulty for a pubkey outside the trust graph", + "EventMessageHandler canAcceptEvent pubkey pow (adaptive) wotThresholds requires the full computed difficulty beyond every threshold", + "EventMessageHandler canAcceptEvent pubkey blacklist returns undefined if blacklist is empty", + "EventMessageHandler canAcceptEvent pubkey blacklist returns undefined if pubkey is not blacklisted", + "EventMessageHandler canAcceptEvent pubkey blacklist returns undefined if pubkey is not an exact match in the blacklist", + "EventMessageHandler canAcceptEvent pubkey blacklist returns reason if pubkey is blacklisted", + "EventMessageHandler canAcceptEvent pubkey blacklist returns undefined if pubkey extends a blacklist entry but is not an exact match", + "EventMessageHandler canAcceptEvent pubkey whitelist returns undefined if whitelist is empty", + "EventMessageHandler canAcceptEvent pubkey whitelist returns undefined if pubkey is whitelisted", + "EventMessageHandler canAcceptEvent pubkey whitelist returns reason if pubkey is not an exact match in the whitelist", + "EventMessageHandler canAcceptEvent pubkey whitelist returns reason if pubkey is not whitelisted", + "EventMessageHandler canAcceptEvent pubkey whitelist returns reason if pubkey is not whitelisted by exact match", + "EventMessageHandler canAcceptEvent kind blacklist returns undefined if blacklist is empty", + "EventMessageHandler canAcceptEvent kind blacklist returns undefined if kind is not blacklisted", + "EventMessageHandler canAcceptEvent kind blacklist returns undefined if kind is not blacklisted in range", + "EventMessageHandler canAcceptEvent kind blacklist returns reason if kind is blacklisted in range", + "EventMessageHandler canAcceptEvent kind whitelist returns undefined if whitelist is empty", + "EventMessageHandler canAcceptEvent kind whitelist returns undefined if kind is whitelisted", + "EventMessageHandler canAcceptEvent kind whitelist returns undefined if kind is whitelisted in range", + "EventMessageHandler canAcceptEvent kind whitelist returns reason if kind is blacklisted and whitelisted in range", + "EventMessageHandler canAcceptEvent kind whitelist returns reason if kind is blacklisted and whitelisted", + "EventMessageHandler canAcceptEvent kind whitelist returns reason if kind is not whitelisted", + "EventMessageHandler canAcceptEvent kind whitelist returns reason if kind is not whitelisted in range", + "EventMessageHandler isEventValid returns reason if request to vanish relay tag does not match relay URL", + "EventMessageHandler isEventValid returns undefined if event is valid", + "EventMessageHandler isEventValid returns reason if event id is not valid", + "EventMessageHandler isEventValid returns reason if event signature is not valid", + "EventMessageHandler isEventValid NIP-17 inner event blocking blocks kind 13 (Seal) with a clear rejection message", + "EventMessageHandler isEventValid NIP-17 inner event blocking blocks kind 14 (Direct Message) with a clear rejection message", + "EventMessageHandler isEventValid NIP-17 inner event blocking blocks kind 15 (File Message) with a clear rejection message", + "EventMessageHandler isEventValid NIP-17 inner event blocking does not block a regular kind 1 event", + "EventMessageHandler isEventValid NIP-17 inner event blocking does not block a kind 1059 (Gift Wrap) event", + "EventMessageHandler isEventValid NIP-17 inner event blocking blocks kind 444 (Marmot Welcome rumor) with a clear rejection message", + "EventMessageHandler isEventValid NIP-17 inner event blocking does not block a kind 445 (Marmot Group Event)", + "EventMessageHandler isBlockedByRequestToVanish returns undefined for request to vanish events", + "EventMessageHandler isBlockedByRequestToVanish returns undefined if event pubkey equals relay's own public key", + "EventMessageHandler isRateLimited fulfills with false if limits setting is not set", + "EventMessageHandler isRateLimited fulfills with false if event limits setting is not set", + "EventMessageHandler isRateLimited fulfills with false if rate limits setting is not set", + "EventMessageHandler isRateLimited fulfills with false if rate limits setting is empty", + "EventMessageHandler isRateLimited fulfills with false if event pubkey equals relay's own public key", + "EventMessageHandler isRateLimited skips rate limiter if IP is whitelisted", + "EventMessageHandler isRateLimited calls rate limiter if IP is not whitelisted", + "EventMessageHandler isRateLimited skips rate limiter if pubkey is whitelisted", + "EventMessageHandler isRateLimited calls rate limiter if pubkey is not whitelisted", + "EventMessageHandler isRateLimited calls hit with given rate limit settings", + "EventMessageHandler isRateLimited fulfills with false if not rate limited", + "EventMessageHandler isRateLimited fulfills with the tripped period if rate limited by second rate limit setting", + "EventMessageHandler isRateLimited stops hitting subsequent rate limit windows once one is exceeded", + "EventMessageHandler isRateLimited fails closed when the rate limiter backend is unavailable", + "EventMessageHandler isUserAdmitted fulfills with undefined if payments are disabled", + "EventMessageHandler isUserAdmitted fulfills with undefined if event pubkey equals relay's own public key", + "EventMessageHandler isUserAdmitted fulfills with undefined if fee schedules are not set", + "EventMessageHandler isUserAdmitted fulfills with undefined if admission fee schedules are not set", + "EventMessageHandler isUserAdmitted fulfills with undefined if there are no admission fee schedules", + "EventMessageHandler isUserAdmitted fulfills with undefined if there are no enabled admission fee schedules", + "EventMessageHandler isUserAdmitted fulfills with undefined if admission fee schedule is waived for pubkey", + "EventMessageHandler isUserAdmitted fulfills with undefined if admission fee schedule is waived for event kind", + "EventMessageHandler isUserAdmitted fulfills with undefined if admission fee schedule is waived for event kind range", + "EventMessageHandler isUserAdmitted fulfills with reason if admission fee schedule is not waived for event kind range", + "EventMessageHandler isUserAdmitted fulfills with reason if user is not found", + "EventMessageHandler isUserAdmitted fulfills with reason if user is not admitted", + "EventMessageHandler isUserAdmitted fulfills with reason if user is not admitted", + "EventMessageHandler isUserAdmitted fulfills with reason if user does not meet minimum balance", + "EventMessageHandler isUserAdmitted fulfills with undefined if user is admitted", + "EventMessageHandler isUserAdmitted NIP-43 membership fulfills with restricted reason if user is not admitted and payments are disabled", + "EventMessageHandler isUserAdmitted NIP-43 membership fulfills with restricted reason if user exists but is not admitted", + "EventMessageHandler isUserAdmitted NIP-43 membership fulfills with undefined if user is admitted", + "EventMessageHandler isUserAdmitted NIP-43 membership fulfills with undefined for a join request from a non-member", + "EventMessageHandler isUserAdmitted NIP-43 membership fulfills with undefined for a leave request from a non-member", + "EventMessageHandler isUserAdmitted NIP-43 membership fulfills with undefined for an invite request from a non-member", + "EventMessageHandler isUserAdmitted NIP-43 membership skips the minimum balance check when payments are disabled", + "EventMessageHandler isUserAdmitted caching falls back to repository lookup when cache read fails", + "EventMessageHandler isUserAdmitted caching fulfills with undefined and uses cache hit for admitted user without hitting DB", + "EventMessageHandler isUserAdmitted caching fulfills with reason and uses cache hit for blocked user without hitting DB", + "EventMessageHandler isUserAdmitted caching fulfills with reason and uses cache hit for insufficient balance without hitting DB", + "EventMessageHandler isUserAdmitted caching caches blocked status with 60s ttl when user is not found", + "EventMessageHandler isUserAdmitted caching caches insufficient balance status with 60s ttl when user balance is too low", + "EventMessageHandler isUserAdmitted caching caches admitted status with 300s ttl when user is admitted and has balance", + "EventMessageHandler checkNip05Verification returns undefined if nip05 settings are not set", + "EventMessageHandler checkNip05Verification returns undefined if nip05 mode is disabled", + "EventMessageHandler checkNip05Verification returns undefined if nip05 mode is passive", + "EventMessageHandler checkNip05Verification returns undefined for kind 0 events (SET_METADATA)", + "EventMessageHandler checkNip05Verification returns undefined if event pubkey equals relay public key", + "EventMessageHandler checkNip05Verification returns reason if no verification found for pubkey", + "EventMessageHandler checkNip05Verification returns reason if verification exists but has no lastVerifiedAt", + "EventMessageHandler checkNip05Verification treats isVerified=true with null lastVerifiedAt as unverified (historical/bad data)", + "EventMessageHandler checkNip05Verification returns reason if verification is expired", + "EventMessageHandler checkNip05Verification returns undefined if verification is valid and not expired", + "EventMessageHandler checkNip05Verification allows author when lastVerifiedAt is recent even if isVerified is false (transient re-check failure)", + "EventMessageHandler checkNip05Verification returns reason if domain is blacklisted", + "EventMessageHandler checkNip05Verification returns reason if domain is not in whitelist", + "EventMessageHandler checkNip05Verification returns undefined if domain is in whitelist", + "EventMessageHandler addExpirationMetadata adds expiration metadata when expiration tag is present", + "EventMessageHandler processNip05Metadata does nothing when nip05 settings are undefined", + "EventMessageHandler processNip05Metadata does nothing when nip05 mode is disabled", + "EventMessageHandler processNip05Metadata does nothing for non-kind-0 events", + "EventMessageHandler processNip05Metadata deletes verification when kind-0 has no nip05 in content", + "EventMessageHandler processNip05Metadata ignores delete errors when kind-0 has no nip05 in content", + "EventMessageHandler processNip05Metadata does nothing when nip05 identifier is unparseable", + "EventMessageHandler processNip05Metadata does nothing when domain is not allowed", + "EventMessageHandler processNip05Metadata verifies and upserts on successful verification", + "EventMessageHandler processNip05Metadata upserts with unverified state and nulls lastVerifiedAt on definitive mismatch", + "EventMessageHandler processNip05Metadata increments failureCount from existing row on definitive mismatch", + "EventMessageHandler processNip05Metadata preserves prior isVerified/lastVerifiedAt on transient error", + "EventMessageHandler processNip05Metadata handles verification errors gracefully (thrown by verifier)", + "EventMessageHandler processNip05Metadata works correctly in passive mode", + "EventMessageHandler checkNip05Verification returns undefined if nip05 settings are not set", + "EventMessageHandler checkNip05Verification returns undefined if nip05 mode is disabled", + "EventMessageHandler checkNip05Verification returns undefined if nip05 mode is passive", + "EventMessageHandler checkNip05Verification returns undefined for kind 0 events (SET_METADATA)", + "EventMessageHandler checkNip05Verification returns undefined if event pubkey equals relay public key", + "EventMessageHandler checkNip05Verification returns reason if no verification found for pubkey", + "EventMessageHandler checkNip05Verification returns reason if verification exists but has no lastVerifiedAt", + "EventMessageHandler checkNip05Verification treats isVerified=true with null lastVerifiedAt as unverified (historical/bad data)", + "EventMessageHandler checkNip05Verification returns reason if verification is expired", + "EventMessageHandler checkNip05Verification returns undefined if verification is valid and not expired", + "EventMessageHandler checkNip05Verification allows author when lastVerifiedAt is recent even if isVerified is false (transient re-check failure)", + "EventMessageHandler checkNip05Verification returns reason if domain is blacklisted", + "EventMessageHandler checkNip05Verification returns reason if domain is not in whitelist", + "EventMessageHandler checkNip05Verification returns undefined if domain is in whitelist", + "EventMessageHandler processNip05Metadata does nothing when nip05 settings are undefined", + "EventMessageHandler processNip05Metadata does nothing when nip05 mode is disabled", + "EventMessageHandler processNip05Metadata does nothing for non-kind-0 events", + "EventMessageHandler processNip05Metadata deletes verification when kind-0 has no nip05 in content", + "EventMessageHandler processNip05Metadata does nothing when nip05 identifier is unparseable", + "EventMessageHandler processNip05Metadata does nothing when domain is not allowed", + "EventMessageHandler processNip05Metadata verifies and upserts on successful verification", + "EventMessageHandler processNip05Metadata upserts with unverified state and nulls lastVerifiedAt on definitive mismatch", + "EventMessageHandler processNip05Metadata increments failureCount from existing row on definitive mismatch", + "EventMessageHandler processNip05Metadata preserves prior isVerified/lastVerifiedAt on transient error", + "EventMessageHandler processNip05Metadata handles verification errors gracefully (thrown by verifier)", + "EventMessageHandler processNip05Metadata works correctly in passive mode", + "EventMessageHandler isAuthenticationRequired returns undefined when authRequired is disabled", + "EventMessageHandler isAuthenticationRequired returns auth-required when enabled and the author is not authenticated", + "EventMessageHandler isAuthenticationRequired returns undefined when enabled and the author is authenticated", + "EventMessageHandler isProtectedEventBlocked returns reason if event has a protected tag and user is not authenticated", + "EventMessageHandler isProtectedEventBlocked returns undefined if event has a protected tag and user is authenticated", + "EventMessageHandler isProtectedEventBlocked returns undefined if event has no protected tag", + "EventMessageHandler isProtectedEventBlocked returns undefined if event has no tags", + "EventMessageHandler isProtectedEventBlocked returns reason if kind 6 repost embeds a valid protected event", + "EventMessageHandler isProtectedEventBlocked returns reason if kind 6 repost embeds a repost that embeds a valid protected event (nested)", + "EventMessageHandler isProtectedEventBlocked returns undefined if kind 6 repost embeds a valid non-protected event", + "EventMessageHandler isProtectedEventBlocked returns undefined if embedded event has invalid id/sig (forged protected tag)", + "EventMessageHandler isProtectedEventBlocked returns undefined if kind 6 repost has empty content", + "EventMessageHandler isProtectedEventBlocked returns undefined if kind 6 repost has invalid JSON content", + "EventMessageHandler isProtectedEventBlocked returns undefined for non-repost event kinds with JSON content", + "EventMessageHandler isProtectedEventBlocked rejects on the outer protected tag before checking embedded repost content", + "EventMessageHandler isProtectedEventBlocked returns undefined if kind 6 repost has non-array embedded tags" + ] + }, + { + "file": "test/unit/handlers/subscribe-message-handler.spec.ts", + "cases": 58, + "titles": [ + "SubscribeMessageHandler #handleMessage emits notice message if subscription is rejected", + "SubscribeMessageHandler #handleMessage emits subscribe event if subscription is accepted", + "SubscribeMessageHandler #handleMessage closes subscription with auth-required if unauthenticated client requests only restricted kinds", + "SubscribeMessageHandler #handleMessage accepts restricted-kind subscription if client is authenticated", + "SubscribeMessageHandler #handleMessage accepts mixed-kind subscription from unauthenticated client", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests when every guard passes emits exactly one claim event followed by EOSE", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests when every guard passes emits a kind 28935 event carrying the minted claim code", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests when every guard passes signs the event as the derived relay pubkey with a valid id and signature", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests when every guard passes tags the event as NIP-70 protected", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests when every guard passes adds a NIP-40 expiration tag mirroring inviteCodeExpirySeconds", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests when every guard passes omits the expiration tag when codes never expire", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests when every guard passes records the requesting pubkey as createdBy", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests when every guard passes never persists the claim event", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests when every guard passes never broadcasts the claim event", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests when every guard passes mints for a pubkey on a non-empty inviteRequestWhitelist", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests when every guard passes mints when info.self matches the derived signing pubkey", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests guards skips when info.self does not match the derived signing pubkey", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests guards skips when info.self is a malformed npub", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests guards skips when NIP-43 is disabled", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests guards skips when allowInviteRequests is not enabled", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests guards skips when the nip43 block is absent entirely", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests guards skips when the requester is not NIP-42 authenticated", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests guards skips when a non-empty inviteRequestWhitelist does not include the requester", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests guards skips when the per-pubkey rate limit is exceeded", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests guards fails closed when the rate limiter is unavailable", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests guards does not consult the rate limiter when no invite rate limits are configured", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests boundaries does not mint for a REQ that does not ask for kind 28935", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests boundaries does not mint for a filter with no kinds at all", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests boundaries mints exactly one code for two non-identical filters naming 28935", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests boundaries serves stored events alongside the claim event on a mixed filter", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests boundaries still sends EOSE when the invite code repository throws", + "SubscribeMessageHandler #handleMessage NIP-43 invite requests boundaries still sends EOSE when the relay private key cannot be derived", + "SubscribeMessageHandler #fetchAndSend does not send event if client is not subscribed to it", + "SubscribeMessageHandler #fetchAndSend sends event if client is subscribed", + "SubscribeMessageHandler #fetchAndSend does not send expired events", + "SubscribeMessageHandler #fetchAndSend sends event if expiration is in the future", + "SubscribeMessageHandler #fetchAndSend does not send restricted-kind events to unauthenticated clients", + "SubscribeMessageHandler #fetchAndSend sends restricted-kind events to the authenticated recipient", + "SubscribeMessageHandler #fetchAndSend sends restricted-kind events to the authenticated author", + "SubscribeMessageHandler #fetchAndSend sends EOSE", + "SubscribeMessageHandler #fetchAndSend ends event stream if error occurs", + "SubscribeMessageHandler #fetchAndSend destroys event stream if aborted", + "SubscribeMessageHandler #fetchAndSend aborts and destroys the event stream when abort() is called", + "SubscribeMessageHandler .isClientSubscribedToEvent returns false if event matches no filter", + "SubscribeMessageHandler .isClientSubscribedToEvent returns true if event matches filter", + "SubscribeMessageHandler #canSubscribe returns undefined if subscription & filter count are allowed", + "SubscribeMessageHandler #canSubscribe returns undefined if max subscription limit is disabled", + "SubscribeMessageHandler #canSubscribe returns undefined if filters limit is disabled", + "SubscribeMessageHandler #canSubscribe returns reason if client is sending a duplicate subscription", + "SubscribeMessageHandler #canSubscribe returns reason if client subscriptions exceed limits", + "SubscribeMessageHandler #canSubscribe returns reason if filter count exceeds limit", + "SubscribeMessageHandler #canSubscribe returns undefined if filter value count limit is disabled", + "SubscribeMessageHandler #canSubscribe returns undefined if a filter is at the value count limit, ignoring scalar criteria", + "SubscribeMessageHandler #canSubscribe returns reason if a filter exceeds the value count limit", + "SubscribeMessageHandler #canSubscribe counts filter values across every array criterion of a filter", + "SubscribeMessageHandler #canSubscribe returns reason if filter limit exceeds max limit", + "SubscribeMessageHandler #canSubscribe returns reason if subscription id is too long", + "SubscribeMessageHandler #canSubscribe returns undefined if subscription id matches max length" + ] + }, + { + "file": "test/unit/handlers/count-message-handler.spec.ts", + "cases": 10, + "titles": [ + "CountMessageHandler handleMessage() returns COUNT with the result when counting works", + "CountMessageHandler handleMessage() drops duplicate filters before querying the repository", + "CountMessageHandler handleMessage() returns CLOSED when the request has too many filters", + "CountMessageHandler handleMessage() returns CLOSED when a filter has too many values", + "CountMessageHandler handleMessage() returns CLOSED when the query ID is too long", + "CountMessageHandler handleMessage() returns CLOSED when counting fails in the repository", + "CountMessageHandler handleMessage() returns CLOSED with auth-required when counting restricted kinds unauthenticated", + "CountMessageHandler handleMessage() returns CLOSED when counting restricted kinds scoped to another pubkey", + "CountMessageHandler handleMessage() returns COUNT when restricted kinds are scoped to the authenticated client", + "CountMessageHandler handleMessage() returns CLOSED when COUNT is disabled in settings" + ] + }, + { + "file": "test/unit/handlers/auth-message-handler.spec.ts", + "cases": 12, + "titles": [ + "AuthMessageHandler handleMessage() authenticates successfully with a valid AUTH event", + "AuthMessageHandler handleMessage() rejects when kind is not 22242", + "AuthMessageHandler handleMessage() rejects when event ID does not match", + "AuthMessageHandler handleMessage() rejects when signature is invalid", + "AuthMessageHandler handleMessage() rejects when created_at is too far in the past", + "AuthMessageHandler handleMessage() rejects when created_at is too far in the future", + "AuthMessageHandler handleMessage() rejects when challenge tag does not match", + "AuthMessageHandler handleMessage() rejects when relay tag does not match", + "AuthMessageHandler handleMessage() rejects when relay tag has invalid URL", + "AuthMessageHandler handleMessage() accepts relay URL with different path but same domain", + "AuthMessageHandler handleMessage() accepts timestamp exactly at the 10-minute boundary", + "AuthMessageHandler handleMessage() rejects when the AUTH event id was already accepted on this socket" + ] + }, + { + "file": "test/unit/handlers/unsubscribe-message-handler.spec.ts", + "cases": 1, + "titles": [ + "UnsubscribeMessageHandler handleMessage() emits unsubscribe event with subscription Id" + ] + }, + { + "file": "test/unit/handlers/event-strategies/default-event-strategy.spec.ts", + "cases": 4, + "titles": [ + "DefaultEventStrategy execute creates event", + "DefaultEventStrategy execute broadcast event if event is created", + "DefaultEventStrategy execute does not broadcast event if event is duplicate", + "DefaultEventStrategy execute rejects if unable to create event" + ] + }, + { + "file": "test/unit/adapters/web-socket-adapter.spec.ts", + "cases": 60, + "titles": [ + "WebSocketAdapter constructor extracts clientId from sec-websocket-key header", + "WebSocketAdapter constructor resolves client address from request", + "WebSocketAdapter constructor registers WebSocket event listeners", + "WebSocketAdapter constructor registers internal event listeners", + "WebSocketAdapter getClientId returns the client ID", + "WebSocketAdapter getClientAddress returns the client IP address", + "WebSocketAdapter getSubscriptions returns an empty map when no subscriptions", + "WebSocketAdapter getSubscriptions returns a copy of subscriptions map", + "WebSocketAdapter onSubscribed adds subscription to the map", + "WebSocketAdapter onSubscribed overwrites existing subscription with same id", + "WebSocketAdapter onUnsubscribed removes subscription from the map", + "WebSocketAdapter onUnsubscribed does not throw when removing non-existent subscription", + "WebSocketAdapter onBroadcast emits broadcast event on the WebSocket server adapter", + "WebSocketAdapter onHeartbeat pings the client and sets alive to false", + "WebSocketAdapter onHeartbeat closes connection when client is not alive and has no subscriptions", + "WebSocketAdapter onHeartbeat closes when client is not alive even if it has active subscriptions", + "WebSocketAdapter sendMessage (via Message event) sends JSON-serialized message when WebSocket is OPEN", + "WebSocketAdapter sendMessage (via Message event) does nothing when WebSocket is not OPEN", + "WebSocketAdapter onSendEvent (via Event event) sends event matching subscription filters", + "WebSocketAdapter onSendEvent (via Event event) does not send event not matching any filter", + "WebSocketAdapter onSendEvent (via Event event) does not send restricted-kind event to unauthenticated client", + "WebSocketAdapter onSendEvent (via Event event) does not send restricted-kind event to a client authenticated as somebody else", + "WebSocketAdapter onSendEvent (via Event event) sends restricted-kind event to the authenticated recipient", + "WebSocketAdapter onSendEvent (via Event event) sends restricted-kind event to the authenticated author", + "WebSocketAdapter onSendEvent (via Event event) NIP-56: hidden content does not broadcast an event matching an actionable report when hideActionableReports is enabled", + "WebSocketAdapter onSendEvent (via Event event) NIP-56: hidden content still broadcasts a hidden-target event when hideActionableReports is disabled", + "WebSocketAdapter onSendEvent (via Event event) NIP-56: hidden content still broadcasts an unrelated event when hideActionableReports is enabled", + "WebSocketAdapter onClientClose clears all subscriptions when client disconnects", + "WebSocketAdapter onClientClose removes all listeners from client", + "WebSocketAdapter onClientPong marks client as alive", + "WebSocketAdapter onClientPing responds with pong", + "WebSocketAdapter error handling closes client on RangeError with max payload exceeded", + "WebSocketAdapter error handling closes client on RSV1 compression error", + "WebSocketAdapter error handling closes client on generic errors", + "WebSocketAdapter onClientMessage handles invalid JSON gracefully", + "WebSocketAdapter onClientMessage stops hitting subsequent rate limit windows once one is exceeded", + "WebSocketAdapter onClientMessage does not rate limit when no rateLimits are configured", + "WebSocketAdapter onClientMessage does not rate limit when client IP is whitelisted", + "WebSocketAdapter onClientMessage sets alive to true when message is received", + "WebSocketAdapter onClientMessage handles AbortError without sending notice", + "WebSocketAdapter onClientMessage returns early when no handler is found for message", + "WebSocketAdapter onClientMessage when rate limited sends a rate-limited notice for non-read messages", + "WebSocketAdapter onClientMessage when rate limited closes a REQ with the tripped window as backoff hint", + "WebSocketAdapter onClientMessage when rate limited ends an active subscription and aborts its in-flight read before CLOSED", + "WebSocketAdapter onClientMessage when rate limited closes a COUNT with the tripped window as backoff hint", + "WebSocketAdapter onClientMessage when rate limited falls back to the notice when the frame is not valid JSON", + "WebSocketAdapter onSendEvent edge cases sends event to multiple matching subscriptions", + "WebSocketAdapter onSendEvent edge cases does not send when socket is not OPEN", + "WebSocketAdapter getSubscriptions edge cases returns a copy that does not affect internal state", + "WebSocketAdapter IPv6 support handles IPv6 client address", + "WebSocketAdapter NIP-42 authentication sends AUTH challenge message on construction", + "WebSocketAdapter NIP-42 authentication getChallenge returns a non-empty string", + "WebSocketAdapter NIP-42 authentication getChallenge returns consistent value for the same adapter", + "WebSocketAdapter NIP-42 authentication getAuthenticatedPubkeys returns empty set initially", + "WebSocketAdapter NIP-42 authentication addAuthenticatedPubkey adds a pubkey without rotating the challenge", + "WebSocketAdapter NIP-42 authentication addAuthenticatedPubkey supports multiple pubkeys", + "WebSocketAdapter NIP-42 authentication addAuthenticatedPubkey deduplicates same pubkey", + "WebSocketAdapter NIP-42 authentication rejects replayed AUTH event ids", + "WebSocketAdapter NIP-42 authentication expires authenticated pubkeys after sessionExpirySeconds", + "WebSocketAdapter NIP-42 authentication generates different challenges for different adapters" + ] + }, + { + "file": "test/unit/adapters/web-socket-server-adapter.spec.ts", + "cases": 17, + "titles": [ + "WebSocketServerAdapter constructor registers broadcast event listener on itself", + "WebSocketServerAdapter constructor registers connection event listener on webSocketServer", + "WebSocketServerAdapter constructor registers error event listener on webSocketServer", + "WebSocketServerAdapter getConnectedClients returns 0 when no clients are connected", + "WebSocketServerAdapter getConnectedClients counts only clients with OPEN readyState", + "WebSocketServerAdapter close calls parent close which closes webServer", + "WebSocketServerAdapter close drains connected WebSocket clients before closing the server", + "WebSocketServerAdapter close terminates clients that remain open after the drain timeout", + "WebSocketServerAdapter close closes the webSocketServer after draining clients", + "WebSocketServerAdapter close invokes callback after full close", + "WebSocketServerAdapter close removes all listeners from webSocketServer after close", + "WebSocketServerAdapter onBroadcast emits event to adapters of all OPEN clients", + "WebSocketServerAdapter onBroadcast skips clients that are not in OPEN state", + "WebSocketServerAdapter onHeartbeat emits heartbeat to connected adapters on the heartbeat interval", + "WebSocketServerAdapter onConnection creates a WebSocketAdapter for new connection", + "WebSocketServerAdapter onConnection terminates rate-limited connections", + "WebSocketServerAdapter onConnection rejects new connections while draining" + ] + }, + { + "file": "test/unit/utils/nip42.spec.ts", + "cases": 27, + "titles": [ + "nip42 isAuthRequired returns false when unset or disabled", + "nip42 isAuthRequired returns true when enabled", + "nip42 getRestrictedReadKinds returns empty array when settings are undefined", + "nip42 getRestrictedReadKinds returns empty array when restrictedReads is disabled", + "nip42 getRestrictedReadKinds returns configured kinds when enabled", + "nip42 getRestrictedReadKinds returns default kinds when enabled without explicit kinds", + "nip42 isClientAuthorizedToReadMention returns false when no pubkeys are authenticated", + "nip42 isClientAuthorizedToReadMention returns true for the event author", + "nip42 isClientAuthorizedToReadMention returns true for a p-tagged recipient", + "nip42 isClientAuthorizedToReadMention returns false for an unrelated authenticated pubkey", + "nip42 createReadAuthorizationGuard allows everything when the feature is disabled", + "nip42 createReadAuthorizationGuard allows unrestricted kinds without consulting authentication", + "nip42 createReadAuthorizationGuard blocks restricted kinds for unauthenticated clients", + "nip42 createReadAuthorizationGuard allows restricted kinds for the tagged recipient", + "nip42 createReadAuthorizationGuard supports kind ranges", + "nip42 isSubscriptionAuthRequired returns false when the feature is disabled", + "nip42 isSubscriptionAuthRequired returns true when every filter exclusively targets restricted kinds", + "nip42 isSubscriptionAuthRequired returns false when any filter includes an unrestricted kind", + "nip42 isSubscriptionAuthRequired returns false for filters without kinds", + "nip42 isSubscriptionAuthRequired returns false when the client is authenticated", + "nip42 isCountAuthorized returns true when the feature is disabled", + "nip42 isCountAuthorized returns true when no filter targets restricted kinds", + "nip42 isCountAuthorized returns false for unauthenticated restricted-kind counts", + "nip42 isCountAuthorized returns false when scoped to somebody else", + "nip42 isCountAuthorized returns true when #p is scoped to the authenticated client", + "nip42 isCountAuthorized returns true when authors are scoped to the authenticated client", + "nip42 isCountAuthorized returns false for unscoped restricted-kind counts even when authenticated" + ] + }, + { + "file": "test/unit/utils/nip42-session.spec.ts", + "cases": 9, + "titles": [ + "Nip42SessionManager issues a non-empty challenge on construction", + "Nip42SessionManager rotateChallenge replaces the active challenge", + "Nip42SessionManager authenticate adds a pubkey to the session", + "Nip42SessionManager rejects replayed AUTH event ids", + "Nip42SessionManager supports multiple authenticated pubkeys", + "Nip42SessionManager clear removes one pubkey or the whole session", + "Nip42SessionManager does not expire sessions when TTL is unset or non-positive", + "Nip42SessionManager expires sessions after the configured TTL", + "Nip42SessionManager does not extend TTL when the same AUTH event is replayed" + ] + }, + { + "file": "test/unit/utils/messages.spec.ts", + "cases": 14, + "titles": [ + "createNotice returns a notice message", + "createOutgoingEventMessage returns an event message", + "createEndOfStoredEventsNoticeMessage returns a EOSE message", + "createCommandResult returns a command result message", + "createCommandResult returns an OK message with success=true and a reason", + "createCommandResult returns an OK message with success=false and a rejection reason", + "createCommandResult appends optional params as a fifth element", + "createClosedMessage returns a CLOSED message without params", + "createClosedMessage appends optional params as a fourth element", + "createSubscriptionMessage returns a REQ message with a single filter", + "createSubscriptionMessage returns a REQ message with multiple filters", + "createSubscriptionMessage returns a subscription message with filters", + "createRelayedEventMessage returns an EVENT message without secret when secret is missing", + "createRelayedEventMessage returns an EVENT message with secret when provided" + ] + }, + { + "file": "test/unit/utils/filter.spec.ts", + "cases": 9, + "titles": [ + "isGenericTagQuery returns true for #a", + "isGenericTagQuery returns true for #A", + "isGenericTagQuery returns false for #0", + "isGenericTagQuery returns false for #abc", + "countFilterValues returns zero for a filter without array criteria", + "countFilterValues sums values across every array criterion", + "countFilterValues counts generic tag criteria", + "countFilterValues does not count scalar criteria", + "countFilterValues does not count empty criteria" + ] + }, + { + "file": "test/unit/utils/ewma-rate-limiter.spec.ts", + "cases": 6, + "titles": [ + "EWMARateLimiter calculateEWMA returns 1 on first request with no history", + "EWMARateLimiter calculateEWMA increases rate on burst requests with no time gap", + "EWMARateLimiter calculateEWMA decays rate after time gap", + "EWMARateLimiter calculateEWMA rate approaches 1 after very long inactivity", + "EWMARateLimiter hit returns false on first request", + "EWMARateLimiter hit returns true when rate limit exceeded" + ] + }, + { + "file": "test/unit/utils/sliding-window-rate-limiter.spec.ts", + "cases": 3, + "titles": [ + "SlidingWindowRateLimiter returns true if rate limited", + "SlidingWindowRateLimiter returns false if not rate limited", + "SlidingWindowRateLimiter robustly handles string return types from Redis" + ] + }, + { + "file": "test/unit/factories/event-strategy-factory.spec.ts", + "cases": 25, + "titles": [ + "eventStrategyFactory returns ReplaceableEvent given a set_metadata event", + "eventStrategyFactory returns ContactListEventStrategy given a contact_list event", + "eventStrategyFactory returns ReplaceableEvent given a replaceable event", + "eventStrategyFactory returns ReplaceableEventStrategy given a relay_list event (NIP-65)", + "eventStrategyFactory returns EphemeralEventStrategy given an ephemeral event", + "eventStrategyFactory returns DeleteEventStrategy given a delete event", + "eventStrategyFactory returns VanishEventStrategy given a request to vanish event", + "eventStrategyFactory returns GiftWrapEventStrategy given a gift wrap event", + "eventStrategyFactory returns GroupEventStrategy given a Marmot group event (kind 445)", + "eventStrategyFactory returns ParameterizedReplaceableEventStrategy given a Marmot KeyPackage event (kind 30443)", + "eventStrategyFactory returns ReplaceableEventStrategy given a Marmot KeyPackage relay list (kind 10051)", + "eventStrategyFactory returns DefaultEventStrategy given a legacy Marmot KeyPackage (kind 443)", + "eventStrategyFactory returns TimestampEventStrategy given an opentimestamps (NIP-03) event", + "eventStrategyFactory returns ParameterizedReplaceableEventStrategy given a delete event", + "eventStrategyFactory returns DefaultEventStrategy given a text_note event", + "eventStrategyFactory returns DefaultEventStrategy given a reaction event (NIP-25)", + "eventStrategyFactory returns DefaultEventStrategy given an external content reaction event (NIP-25)", + "eventStrategyFactory returns JoinRequestEventStrategy given a NIP-43 join request (kind 28934)", + "eventStrategyFactory returns LeaveRequestEventStrategy given a NIP-43 leave request (kind 28936)", + "eventStrategyFactory returns InviteRequestEventStrategy given a NIP-43 invite request (kind 28935)", + "eventStrategyFactory returns DvmJobRequestEventStrategy given a DVM job request (kind 5000-5999)", + "eventStrategyFactory returns DvmJobRequestEventStrategy given the last DVM job request kind (5999)", + "eventStrategyFactory returns ParameterizedReplaceableEventStrategy given a handler recommendation event (NIP-89, kind 31989)", + "eventStrategyFactory returns ParameterizedReplaceableEventStrategy given a handler information event (NIP-89, kind 31990)", + "eventStrategyFactory returns ReportEventStrategy given a report event (NIP-56, kind 1984)" + ] + }, + { + "file": "test/unit/factories/message-handler-factory.spec.ts", + "cases": 6, + "titles": [ + "messageHandlerFactory returns EventMessageHandler when given an EVENT message", + "messageHandlerFactory returns SubscribeMessageHandler when given a REQ message", + "messageHandlerFactory returns UnsubscribeMessageHandler when given a REQ message", + "messageHandlerFactory returns CountMessageHandler when given a COUNT message", + "messageHandlerFactory returns AuthMessageHandler when given an AUTH message", + "messageHandlerFactory throws when given an invalid message" + ] + }, + { + "file": "test/unit/factories/websocket-adapter-factory.spec.ts", + "cases": 1, + "titles": [ + "webSocketAdapterFactory returns a WebSocketAdapter" + ] + }, + { + "file": "test/unit/schemas/event-schema.spec.ts", + "cases": 57, + "titles": [ + "NIP-01 validate event schema returns same event if event is valid", + "NIP-01 validate event schema returns error if unknown key is provided", + "NIP-01 validate event schema id id must be a string", + "NIP-01 validate event schema id id must only contain lowercase characters", + "NIP-01 validate event schema id id must only contain hexadecimal characters", + "NIP-01 validate event schema id id length must be 64 characters long", + "NIP-01 validate event schema id id length must be 64 characters long", + "NIP-01 validate event schema id id is not allowed to be empty", + "NIP-01 validate event schema id id is required", + "NIP-01 validate event schema pubkey pubkey must be a string", + "NIP-01 validate event schema pubkey pubkey must only contain lowercase characters", + "NIP-01 validate event schema pubkey pubkey must only contain hexadecimal characters", + "NIP-01 validate event schema pubkey pubkey length must be 64 characters long", + "NIP-01 validate event schema pubkey pubkey length must be 64 characters long", + "NIP-01 validate event schema pubkey pubkey is not allowed to be empty", + "NIP-01 validate event schema pubkey pubkey is required", + "NIP-01 validate event schema created_at created_at contains an invalid value", + "NIP-01 validate event schema created_at created_at must be a number", + "NIP-01 validate event schema created_at created_at must be greater than or equal to 0", + "NIP-01 validate event schema created_at created_at must be a multiple of 1", + "NIP-01 validate event schema created_at created_at is required", + "NIP-01 validate event schema kind kind must be a number", + "NIP-01 validate event schema kind kind must be greater than or equal to 0", + "NIP-01 validate event schema kind kind must be a multiple of 1", + "NIP-01 validate event schema kind kind is required", + "NIP-01 validate event schema content content must be a string", + "NIP-01 validate event schema content content is required", + "NIP-01 validate event schema sig sig must be a string", + "NIP-01 validate event schema sig sig must only contain lowercase characters", + "NIP-01 validate event schema sig sig must only contain hexadecimal characters", + "NIP-01 validate event schema sig sig length must be 128 characters long", + "NIP-01 validate event schema sig sig length must be 128 characters long", + "NIP-01 validate event schema sig sig is not allowed to be empty", + "NIP-01 validate event schema sig sig is required", + "NIP-01 validate event schema tags tags must be an array", + "NIP-01 validate event schema tags tags is required", + "NIP-01 validate event schema tag tag must be an array", + "NIP-01 validate event schema identifier identifier must be a string", + "NIP-01 validate event schema identifier identifier is not allowed to be empty", + "NIP-01 validate event schema value value must be a string", + "NIP-25 accepts reaction with e tag", + "NIP-25 rejects reaction missing e tag", + "NIP-25 accepts external content reaction with k and i tags", + "NIP-25 rejects external content reaction missing k tag", + "NIP-25 rejects external content reaction missing i tag", + "NIP-65 accepts relay_list event with valid wss relay URL", + "NIP-65 accepts relay_list event with valid wss relay URL and read marker", + "NIP-65 accepts relay_list event with valid wss relay URL and write marker", + "NIP-65 accepts relay_list event with no relay tags", + "NIP-65 rejects relay_list event with invalid relay URL", + "NIP-65 rejects relay_list event with empty relay URL", + "NIP-65 does not validate relay URL on non-relay_list events with r tags", + "NIP-12 accepts event with valid base32 geohash tag", + "NIP-12 rejects event with non-base32 geohash characters", + "NIP-12 rejects event with empty geohash", + "NIP-12 rejects event with uppercase geohash", + "NIP-14 accepts subject tag on text note events" + ] + }, + { + "file": "test/unit/schemas/message-schema.spec.ts", + "cases": 17, + "titles": [ + "NIP-01 validateSchema EVENT returns same message if valid", + "NIP-01 validateSchema CLOSE returns same message if valid", + "NIP-01 validateSchema CLOSE returns error if subscription ID is missing", + "NIP-01 validateSchema REQ returns same message if valid", + "NIP-01 validateSchema REQ returns error if subscription Id is missing", + "NIP-01 validateSchema REQ returns error if filter is not an object", + "NIP-01 validateSchema REQ returns error if filter is missing", + "NIP-01 validateSchema REQ returns error if there are too many filters", + "NIP-01 validateSchema COUNT returns same message if valid", + "NIP-01 validateSchema COUNT returns error if query ID is missing", + "NIP-01 validateSchema COUNT returns error if filter is missing", + "NIP-01 validateSchema COUNT returns error if filter is not an object", + "NIP-01 validateSchema COUNT returns error if there are too many filters", + "NIP-01 validateSchema AUTH returns same message if valid", + "NIP-01 validateSchema AUTH returns error if event is missing", + "NIP-01 validateSchema AUTH returns error if event is not an object", + "NIP-01 validateSchema AUTH returns error if event is null" + ] + }, + { + "file": "test/unit/utils/hidden-content-cache.spec.ts", + "cases": 12, + "titles": [ + "hidden-content-cache markActionableTarget/isHidden hides an event whose pubkey was marked", + "hidden-content-cache markActionableTarget/isHidden hides an event whose id was marked", + "hidden-content-cache markActionableTarget/isHidden does not hide an unmarked event", + "hidden-content-cache markActionableTarget/isHidden ignores a target with both fields null", + "hidden-content-cache startHiddenContentCache seeds the cache from the DB at boot when hideActionableReports is enabled", + "hidden-content-cache startHiddenContentCache does not query the DB at boot when hideActionableReports is disabled", + "hidden-content-cache startHiddenContentCache logs and continues if the initial DB read fails, without throwing", + "hidden-content-cache startHiddenContentCache periodically re-reads the DB and reconciles the in-memory set", + "hidden-content-cache startHiddenContentCache does not poll the DB on later ticks while hideActionableReports stays disabled", + "hidden-content-cache startHiddenContentCache resumes polling within one interval after hideActionableReports is hot-enabled", + "hidden-content-cache startHiddenContentCache stopHiddenContentCache stops further polling", + "hidden-content-cache resetHiddenContentCache clears previously marked targets" + ] + }, + { + "file": "test/unit/utils/shutdown-state.spec.ts", + "cases": 5, + "titles": [ + "shutdown-state tracks draining state", + "shutdown-state defaults WS drain timeout to 30s", + "shutdown-state reads WS drain timeout from env", + "shutdown-state falls back to default for invalid WS drain timeout", + "shutdown-state adds a buffer to the primary shutdown deadline" + ] + }, + { + "file": "test/unit/utils/validation.spec.ts", + "cases": 4, + "titles": [ + "attemptValidation returns value if given value matches schema", + "attemptValidation throws error if given value does not match schema", + "validateSchema returns value property with given value if it matches schema", + "validateSchema returns error property with ZodError if given value does not match schema" + ] + } + ] +} diff --git a/crates/nostream-relay/tests/fixtures/wire-sessions.json b/crates/nostream-relay/tests/fixtures/wire-sessions.json new file mode 100644 index 000000000..1b30fd834 --- /dev/null +++ b/crates/nostream-relay/tests/fixtures/wire-sessions.json @@ -0,0 +1,7480 @@ +{ + "capture": "242a3001d77149e4d70151ec03060c4ef44a1547", + "generated_by": "crates/nostream-relay/tests/capture/gen-wire-sessions.mjs", + "clock": { + "start_ms": 1700000000000 + }, + "seed": 20261023, + "client_secret": "9efae3babe773a59cea1366fac569ac0ea93e49d411b70b70b1f9e4a018ddce9", + "relay_secret": "683c29961bc5247d177a5019710ee3b776fb07ef636d834b848b4f726ace3f51", + "sessions": [ + { + "id": "no-secret", + "challenge": "SSEcBziEn1efjkHMjGjhl6NDlwVzjhK12oblYLRNHmo", + "clock": { + "start_ms": 1700000000000 + }, + "seed": 20261023, + "settings": { + "info": { + "relay_url": "wss://nostream.your-domain.com", + "name": "nostream.your-domain.com", + "description": "A nostr relay written in Typescript.", + "banner": "https://nostream.your-domain.com/banner.png", + "icon": "https://nostream.your-domain.com/icon.png", + "pubkey": "replace-with-your-pubkey-in-hex", + "contact": "mailto:operator@your-domain.com", + "terms_of_service": "https://nostream.your-domain.com/terms" + }, + "payments": { + "enabled": false, + "processor": "zebedee", + "feeSchedules": { + "admission": [ + { + "enabled": false, + "description": "Admission fee charged per public key in msats (1000 msats = 1 satoshi)", + "amount": 1000000, + "whitelists": { + "pubkeys": [ + "replace-with-your-pubkey-in-hex" + ], + "event_kinds": [ + 9735 + ] + } + } + ] + }, + "invoiceExpirySeconds": 86400 + }, + "paymentsProcessors": { + "zebedee": { + "baseURL": "https://api.zebedee.io/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/zebedee", + "ipWhitelist": [ + "3.225.112.64", + "::ffff:3.225.112.64" + ] + }, + "lnbits": { + "baseURL": "https://lnbits.your-domain.com/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/lnbits" + }, + "lnurl": { + "invoiceURL": "https://getalby.com/lnurlp/your-username" + }, + "nodeless": { + "baseURL": "https://nodeless.io", + "storeId": "your-nodeless-io-store-id" + }, + "opennode": { + "baseURL": "api.opennode.com", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/opennode" + }, + "nwc": { + "invoiceExpirySeconds": 900, + "replyTimeoutMs": 10000 + } + }, + "nip05": { + "mode": "disabled", + "verifyExpiration": 604800000, + "verifyUpdateFrequency": 86400000, + "maxConsecutiveFailures": 20, + "domainWhitelist": [], + "domainBlacklist": [] + }, + "nip42": { + "authRequired": false, + "sessionExpirySeconds": 0, + "restrictedReads": { + "enabled": false, + "kinds": [ + 4, + 1059 + ] + } + }, + "nip43": { + "enabled": false, + "inviteCodeExpirySeconds": 600, + "defaultMaxUses": 1, + "allowInviteRequests": false, + "inviteRequestWhitelist": [] + }, + "nip45": { + "enabled": true + }, + "nip50": { + "enabled": false, + "language": "simple", + "maxQueryLength": 256 + }, + "nip66": { + "enabled": false, + "probeIntervalSeconds": 3600, + "timeouts": { + "dnsMs": 10000, + "tlsMs": 10000, + "wsRttMs": 10000, + "nip11Ms": 10000 + }, + "targets": [], + "dnsCacheTtlSeconds": 300 + }, + "wot": { + "enabled": false, + "seedPubkey": "", + "minimumFollowers": 1, + "maxDepth": 2, + "refreshIntervalHours": 24 + }, + "nip56": { + "enabled": false, + "trustedModerators": [], + "hideActionableReports": false + }, + "network": { + "maxPayloadSize": 524288, + "trustedProxies": [ + "127.0.0.1", + "::ffff:127.0.0.1", + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "workers": { + "count": 0 + }, + "eventStore": { + "backend": "postgres" + }, + "mirroring": { + "static": [] + }, + "dvm": { + "workers": [] + }, + "limits": { + "rateLimiter": { + "strategy": "ewma" + }, + "invoice": { + "rateLimits": [ + { + "period": 60000, + "rate": 12 + }, + { + "period": 3600000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "invite": { + "rateLimits": [ + { + "description": "5 invite requests per hour per pubkey", + "period": 3600000, + "rate": 5 + } + ] + }, + "admissionCheck": { + "rateLimits": [ + { + "description": "30 admission checks/min or 1 check every 2 seconds", + "period": 60000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "admin": { + "rateLimits": [ + { + "description": "30 admin requests/min", + "period": 60000, + "rate": 30 + } + ], + "loginRateLimits": [ + { + "description": "10 login attempts per 15 minutes", + "period": 900000, + "rate": 10 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "connection": { + "rateLimits": [ + { + "period": 1000, + "rate": 12 + }, + { + "period": 60000, + "rate": 48 + } + ], + "ipWhitelist": [ + "127.0.0.1", + "::1", + "::ffff:127.0.0.1" + ] + }, + "event": { + "retention": { + "maxDays": -1, + "kind": { + "whitelist": [ + 62 + ] + }, + "pubkey": { + "whitelist": [] + } + }, + "eventId": { + "minLeadingZeroBits": 0 + }, + "pow": { + "enabled": false, + "floorBits": 0, + "ceilingBits": 24, + "targetEventsPerSecond": 50, + "periodMs": 60000, + "wotThresholds": [] + }, + "kind": { + "whitelist": [], + "blacklist": [] + }, + "pubkey": { + "minBalance": 0, + "minLeadingZeroBits": 0, + "whitelist": [], + "blacklist": [] + }, + "createdAt": { + "maxPositiveDelta": 900, + "maxNegativeDelta": 0 + }, + "content": [ + { + "description": "64 KB for event kind ranges 0-10 and 40-49", + "maxLength": 102400, + "kinds": [ + [ + 0, + 10 + ], + [ + 40, + 49 + ] + ] + }, + { + "description": "96 KB for event kind ranges 11-39 and 50-max", + "maxLength": 102400, + "kinds": [ + [ + 11, + 39 + ], + [ + 50, + 9007199254740991 + ] + ] + } + ], + "rateLimits": [ + { + "description": "6 events/min for event kinds 0, 3, 40 and 41", + "kinds": [ + 0, + 3, + 40, + 41 + ], + "period": 60000, + "rate": 6 + }, + { + "description": "12 events/min for event kinds 1, 2, 4 and 42", + "kinds": [ + 1, + 2, + 4, + 42 + ], + "period": 60000, + "rate": 12 + }, + { + "description": "30 events/min for event kind ranges 5-7 and 43-49", + "kinds": [ + [ + 5, + 7 + ], + [ + 43, + 49 + ] + ], + "period": 60000, + "rate": 30 + }, + { + "description": "24 events/min for replaceable events and parameterized replaceable events", + "kinds": [ + [ + 10000, + 19999 + ], + [ + 30000, + 39999 + ] + ], + "period": 60000, + "rate": 24 + }, + { + "description": "60 events/min for Marmot group events (kind 445)", + "kinds": [ + 445 + ], + "period": 60000, + "rate": 60 + }, + { + "description": "60 events/min for ephemeral events", + "kinds": [ + [ + 20000, + 29999 + ] + ], + "period": 60000, + "rate": 60 + }, + { + "description": "720 events/hour for all events", + "period": 3600000, + "rate": 720 + } + ], + "whitelists": { + "pubkeys": [], + "ipAddresses": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "client": { + "subscription": { + "maxSubscriptions": 10, + "maxFilters": 10, + "maxFilterValues": 2500, + "maxSubscriptionIdLength": 256, + "maxLimit": 5000, + "minPrefixLength": 4 + } + }, + "message": { + "rateLimits": [ + { + "description": "240 raw messages/min", + "period": 60000, + "rate": 240 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "admin": { + "enabled": false, + "sessionTtlSeconds": 86400, + "nip98": { + "enabled": false, + "allowedPubkeys": [], + "maxSkewSeconds": 60 + }, + "notifications": { + "enabled": false, + "targets": [], + "events": { + "admission.invoice.created": true, + "admission.invoice.paid": true, + "admission.invoice.failed": true, + "settings.changed": true, + "relay.restarted": false + }, + "retry": { + "maxAttempts": 5, + "baseDelayMs": 1000 + }, + "deliveryLogRetentionDays": 30 + } + } + }, + "steps": [ + { + "connect": true, + "expect": [ + "[\"AUTH\",\"{{challenge}}\"]" + ] + }, + { + "send": { + "text": "[\"EVENT\",{\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"kind\":1,\"created_at\":1700000000,\"content\":\"no secret\",\"tags\":[],\"id\":\"81d4e3319401a592ebbdce37b423cc2bea63ccd5ae2250ac7d95a646826026e6\",\"sig\":\"f28890409cfa30ebc46076929543021471666079565d598f050c7f75ed448656482e3a89cd465527e2706e1f27487accb019ab83d9a1b778d28c7aa040e9d149\"}]" + }, + "expect": [] + } + ], + "random": { + "challenge": "49211c0738849f579f8e41cc8c68e197a3439705738e12b5da86e560b44d1e6a", + "invite_code": null + }, + "comparison": "skipped", + "comparison_why": "the capture logs the missing SECRET and sends no frame; the Rust relay answers [\"OK\",,false,\"error: unable to process event\"] (AC29, measured by handlers.rs) \u2014 job 008" + }, + { + "id": "measured-session", + "challenge": "hfoCe6DPqumXqELoYYrMRolNJ5QtRUgr8iMOzpHCb-Q", + "clock": { + "start_ms": 1700000000000 + }, + "seed": 20261023, + "settings": { + "info": { + "relay_url": "wss://nostream.your-domain.com", + "name": "nostream.your-domain.com", + "description": "A nostr relay written in Typescript.", + "banner": "https://nostream.your-domain.com/banner.png", + "icon": "https://nostream.your-domain.com/icon.png", + "pubkey": "replace-with-your-pubkey-in-hex", + "contact": "mailto:operator@your-domain.com", + "terms_of_service": "https://nostream.your-domain.com/terms" + }, + "payments": { + "enabled": false, + "processor": "zebedee", + "feeSchedules": { + "admission": [ + { + "enabled": false, + "description": "Admission fee charged per public key in msats (1000 msats = 1 satoshi)", + "amount": 1000000, + "whitelists": { + "pubkeys": [ + "replace-with-your-pubkey-in-hex" + ], + "event_kinds": [ + 9735 + ] + } + } + ] + }, + "invoiceExpirySeconds": 86400 + }, + "paymentsProcessors": { + "zebedee": { + "baseURL": "https://api.zebedee.io/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/zebedee", + "ipWhitelist": [ + "3.225.112.64", + "::ffff:3.225.112.64" + ] + }, + "lnbits": { + "baseURL": "https://lnbits.your-domain.com/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/lnbits" + }, + "lnurl": { + "invoiceURL": "https://getalby.com/lnurlp/your-username" + }, + "nodeless": { + "baseURL": "https://nodeless.io", + "storeId": "your-nodeless-io-store-id" + }, + "opennode": { + "baseURL": "api.opennode.com", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/opennode" + }, + "nwc": { + "invoiceExpirySeconds": 900, + "replyTimeoutMs": 10000 + } + }, + "nip05": { + "mode": "disabled", + "verifyExpiration": 604800000, + "verifyUpdateFrequency": 86400000, + "maxConsecutiveFailures": 20, + "domainWhitelist": [], + "domainBlacklist": [] + }, + "nip42": { + "authRequired": false, + "sessionExpirySeconds": 0, + "restrictedReads": { + "enabled": false, + "kinds": [ + 4, + 1059 + ] + } + }, + "nip43": { + "enabled": false, + "inviteCodeExpirySeconds": 600, + "defaultMaxUses": 1, + "allowInviteRequests": false, + "inviteRequestWhitelist": [] + }, + "nip45": { + "enabled": true + }, + "nip50": { + "enabled": false, + "language": "simple", + "maxQueryLength": 256 + }, + "nip66": { + "enabled": false, + "probeIntervalSeconds": 3600, + "timeouts": { + "dnsMs": 10000, + "tlsMs": 10000, + "wsRttMs": 10000, + "nip11Ms": 10000 + }, + "targets": [], + "dnsCacheTtlSeconds": 300 + }, + "wot": { + "enabled": false, + "seedPubkey": "", + "minimumFollowers": 1, + "maxDepth": 2, + "refreshIntervalHours": 24 + }, + "nip56": { + "enabled": false, + "trustedModerators": [], + "hideActionableReports": false + }, + "network": { + "maxPayloadSize": 524288, + "trustedProxies": [ + "127.0.0.1", + "::ffff:127.0.0.1", + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "workers": { + "count": 0 + }, + "eventStore": { + "backend": "postgres" + }, + "mirroring": { + "static": [] + }, + "dvm": { + "workers": [] + }, + "limits": { + "rateLimiter": { + "strategy": "ewma" + }, + "invoice": { + "rateLimits": [ + { + "period": 60000, + "rate": 12 + }, + { + "period": 3600000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "invite": { + "rateLimits": [ + { + "description": "5 invite requests per hour per pubkey", + "period": 3600000, + "rate": 5 + } + ] + }, + "admissionCheck": { + "rateLimits": [ + { + "description": "30 admission checks/min or 1 check every 2 seconds", + "period": 60000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "admin": { + "rateLimits": [ + { + "description": "30 admin requests/min", + "period": 60000, + "rate": 30 + } + ], + "loginRateLimits": [ + { + "description": "10 login attempts per 15 minutes", + "period": 900000, + "rate": 10 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "connection": { + "rateLimits": [ + { + "period": 1000, + "rate": 12 + }, + { + "period": 60000, + "rate": 48 + } + ], + "ipWhitelist": [ + "127.0.0.1", + "::1", + "::ffff:127.0.0.1" + ] + }, + "event": { + "retention": { + "maxDays": -1, + "kind": { + "whitelist": [ + 62 + ] + }, + "pubkey": { + "whitelist": [] + } + }, + "eventId": { + "minLeadingZeroBits": 0 + }, + "pow": { + "enabled": false, + "floorBits": 0, + "ceilingBits": 24, + "targetEventsPerSecond": 50, + "periodMs": 60000, + "wotThresholds": [] + }, + "kind": { + "whitelist": [], + "blacklist": [] + }, + "pubkey": { + "minBalance": 0, + "minLeadingZeroBits": 0, + "whitelist": [], + "blacklist": [] + }, + "createdAt": { + "maxPositiveDelta": 900, + "maxNegativeDelta": 0 + }, + "content": [ + { + "description": "64 KB for event kind ranges 0-10 and 40-49", + "maxLength": 102400, + "kinds": [ + [ + 0, + 10 + ], + [ + 40, + 49 + ] + ] + }, + { + "description": "96 KB for event kind ranges 11-39 and 50-max", + "maxLength": 102400, + "kinds": [ + [ + 11, + 39 + ], + [ + 50, + 9007199254740991 + ] + ] + } + ], + "rateLimits": [ + { + "description": "6 events/min for event kinds 0, 3, 40 and 41", + "kinds": [ + 0, + 3, + 40, + 41 + ], + "period": 60000, + "rate": 6 + }, + { + "description": "12 events/min for event kinds 1, 2, 4 and 42", + "kinds": [ + 1, + 2, + 4, + 42 + ], + "period": 60000, + "rate": 12 + }, + { + "description": "30 events/min for event kind ranges 5-7 and 43-49", + "kinds": [ + [ + 5, + 7 + ], + [ + 43, + 49 + ] + ], + "period": 60000, + "rate": 30 + }, + { + "description": "24 events/min for replaceable events and parameterized replaceable events", + "kinds": [ + [ + 10000, + 19999 + ], + [ + 30000, + 39999 + ] + ], + "period": 60000, + "rate": 24 + }, + { + "description": "60 events/min for Marmot group events (kind 445)", + "kinds": [ + 445 + ], + "period": 60000, + "rate": 60 + }, + { + "description": "60 events/min for ephemeral events", + "kinds": [ + [ + 20000, + 29999 + ] + ], + "period": 60000, + "rate": 60 + }, + { + "description": "720 events/hour for all events", + "period": 3600000, + "rate": 720 + } + ], + "whitelists": { + "pubkeys": [], + "ipAddresses": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "client": { + "subscription": { + "maxSubscriptions": 10, + "maxFilters": 10, + "maxFilterValues": 2500, + "maxSubscriptionIdLength": 256, + "maxLimit": 5000, + "minPrefixLength": 4 + } + }, + "message": { + "rateLimits": [ + { + "description": "240 raw messages/min", + "period": 60000, + "rate": 240 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "admin": { + "enabled": false, + "sessionTtlSeconds": 86400, + "nip98": { + "enabled": false, + "allowedPubkeys": [], + "maxSkewSeconds": 60 + }, + "notifications": { + "enabled": false, + "targets": [], + "events": { + "admission.invoice.created": true, + "admission.invoice.paid": true, + "admission.invoice.failed": true, + "settings.changed": true, + "relay.restarted": false + }, + "retry": { + "maxAttempts": 5, + "baseDelayMs": 1000 + }, + "deliveryLogRetentionDays": 30 + } + } + }, + "steps": [ + { + "connect": true, + "expect": [ + "[\"AUTH\",\"{{challenge}}\"]" + ] + }, + { + "send": { + "text": "{not json" + }, + "allow": [ + { + "why": "T1", + "detail": "the JSON syntax message is V8-specific; the prefix is not" + } + ], + "expect": [ + "[\"NOTICE\",\"invalid: Expected property name or '}' in JSON at position 1 (line 1 column 2)\"]" + ] + }, + { + "send": { + "text": "[\"BOGUS\",\"x\"]" + }, + "expect": [ + "[\"NOTICE\",\"invalid: Invalid input\"]" + ] + }, + { + "send": { + "text": "[\"REQ\",\"s-empty\"]" + }, + "expect": [ + "[\"NOTICE\",\"invalid: REQ message must contain at least one filter\"]" + ] + }, + { + "send": { + "text": "[\"REQ\",\"s1\",{\"kinds\":[1]}]" + }, + "expect": [ + "[\"EOSE\",\"s1\"]" + ] + }, + { + "send": { + "text": "[\"COUNT\",\"c1\",{\"kinds\":[1]}]" + }, + "expect": [ + "[\"COUNT\",\"c1\",{\"count\":0}]" + ] + }, + { + "send": { + "text": "[\"REQ\",\"s1\",{\"kinds\":[1]}]" + }, + "expect": [ + "[\"NOTICE\",\"Subscription rejected: Duplicate subscription s1: Ignoring\"]" + ] + }, + { + "send": { + "text": "[\"CLOSE\",\"s1\"]" + }, + "expect": [] + }, + { + "send": { + "text": "[\"EVENT\",{\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"kind\":1,\"created_at\":1700000000,\"content\":\"wire-session\",\"tags\":[],\"id\":\"d73cc9e9e7bbfb8fbc3ba0ba9b66a5175d1161e7d3fb85c2d72aa2ef33d0edd1\",\"sig\":\"89fc957335b6d7ce82f561d4441881c7401019e830214296a63da7626b16331968c1cc6f70b0176cfe06c6d4ddfb4e90dd1c85aad877845ca21bbded25db895f\"}]" + }, + "expect": [ + "[\"OK\",\"d73cc9e9e7bbfb8fbc3ba0ba9b66a5175d1161e7d3fb85c2d72aa2ef33d0edd1\",true,\"\"]" + ] + }, + { + "send": { + "text": "[\"EVENT\",{\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"kind\":1,\"created_at\":1700000000,\"content\":\"wire-session\",\"tags\":[],\"id\":\"d73cc9e9e7bbfb8fbc3ba0ba9b66a5175d1161e7d3fb85c2d72aa2ef33d0edd1\",\"sig\":\"89fc957335b6d7ce82f561d4441881c7401019e830214296a63da7626b16331968c1cc6f70b0176cfe06c6d4ddfb4e90dd1c85aad877845ca21bbded25db895f\"}]" + }, + "expect": [ + "[\"OK\",\"d73cc9e9e7bbfb8fbc3ba0ba9b66a5175d1161e7d3fb85c2d72aa2ef33d0edd1\",true,\"duplicate:\"]" + ] + }, + { + "send": { + "text": "[\"EVENT\",{\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"kind\":1,\"created_at\":1700000000,\"content\":\"tampered\",\"tags\":[],\"id\":\"d73cc9e9e7bbfb8fbc3ba0ba9b66a5175d1161e7d3fb85c2d72aa2ef33d0edd1\",\"sig\":\"89fc957335b6d7ce82f561d4441881c7401019e830214296a63da7626b16331968c1cc6f70b0176cfe06c6d4ddfb4e90dd1c85aad877845ca21bbded25db895f\"}]" + }, + "expect": [ + "[\"OK\",\"d73cc9e9e7bbfb8fbc3ba0ba9b66a5175d1161e7d3fb85c2d72aa2ef33d0edd1\",false,\"invalid: event id does not match\"]" + ] + }, + { + "send": { + "text": "[\"AUTH\",{\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"kind\":22242,\"created_at\":1700000000,\"content\":\"wire-session\",\"tags\":[[\"relay\",\"wss://nostream.your-domain.com\"],[\"challenge\",\"nope\"]],\"id\":\"8a855a85edffb68d3f46a0d014787e6d9eddfe5ef41d5fc0a01fc5b140567614\",\"sig\":\"2e806daec71299f6196306616b17271c7b1e73b19a04513f55b0634c82e3f2459c1d6aa4fd6e6fbb20d3a76b679528b54fe352983aeea7d3351fd4a36006b4d1\"}]" + }, + "expect": [ + "[\"OK\",\"8a855a85edffb68d3f46a0d014787e6d9eddfe5ef41d5fc0a01fc5b140567614\",false,\"invalid: challenge does not match\"]" + ] + }, + { + "send": { + "text": "[\"AUTH\",{\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"kind\":22242,\"created_at\":1700000000,\"content\":\"wire-session\",\"tags\":[[\"relay\",\"wss://nostream.your-domain.com\"],[\"challenge\",\"{{challenge}}\"]],\"id\":\"a9886bf13aba5c2a2b850784eaa02666346453d65374fee3c490a3fb81093275\",\"sig\":\"181cdc8c5271c8a67c77fb28bc9904e8d3025587477d304befad147b8e9a3d51ac87526ff04613fe385b4813f947d08aa5be83072ab4ae879657041b90df7996\"}]" + }, + "expect": [ + "[\"OK\",\"a9886bf13aba5c2a2b850784eaa02666346453d65374fee3c490a3fb81093275\",true,\"\"]" + ] + }, + { + "send": { + "text": "[\"REQ\",\"s2\",{\"kinds\":[1]}]" + }, + "compare": false, + "compare_why": "a REQ result carries the capture\u2019s stored-row field order; recorded, not compared \u2014 job 008", + "expect": [ + "[\"EVENT\",\"s2\",{\"id\":\"d73cc9e9e7bbfb8fbc3ba0ba9b66a5175d1161e7d3fb85c2d72aa2ef33d0edd1\",\"kind\":1,\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"created_at\":1700000000,\"content\":\"wire-session\",\"tags\":[],\"sig\":\"89fc957335b6d7ce82f561d4441881c7401019e830214296a63da7626b16331968c1cc6f70b0176cfe06c6d4ddfb4e90dd1c85aad877845ca21bbded25db895f\"}]", + "[\"EOSE\",\"s2\"]" + ] + }, + { + "oversized": 614600 + } + ], + "final_close": { + "code": 1009, + "reason": "" + }, + "relay_secret": "683c29961bc5247d177a5019710ee3b776fb07ef636d834b848b4f726ace3f51", + "random": { + "challenge": "85fa027ba0cfaae997a842e8618acc46894d27942d45482bf2230ece91c26fe4", + "invite_code": null + } + }, + { + "id": "rate-limit-message", + "challenge": "HtPRPdIWMhojn_6EErd8FszxluhZxlk3UEmd7Dpm_X8", + "clock": { + "start_ms": 1700000000000 + }, + "seed": 20261023, + "settings": { + "info": { + "relay_url": "wss://nostream.your-domain.com", + "name": "nostream.your-domain.com", + "description": "A nostr relay written in Typescript.", + "banner": "https://nostream.your-domain.com/banner.png", + "icon": "https://nostream.your-domain.com/icon.png", + "pubkey": "replace-with-your-pubkey-in-hex", + "contact": "mailto:operator@your-domain.com", + "terms_of_service": "https://nostream.your-domain.com/terms" + }, + "payments": { + "enabled": false, + "processor": "zebedee", + "feeSchedules": { + "admission": [ + { + "enabled": false, + "description": "Admission fee charged per public key in msats (1000 msats = 1 satoshi)", + "amount": 1000000, + "whitelists": { + "pubkeys": [ + "replace-with-your-pubkey-in-hex" + ], + "event_kinds": [ + 9735 + ] + } + } + ] + }, + "invoiceExpirySeconds": 86400 + }, + "paymentsProcessors": { + "zebedee": { + "baseURL": "https://api.zebedee.io/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/zebedee", + "ipWhitelist": [ + "3.225.112.64", + "::ffff:3.225.112.64" + ] + }, + "lnbits": { + "baseURL": "https://lnbits.your-domain.com/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/lnbits" + }, + "lnurl": { + "invoiceURL": "https://getalby.com/lnurlp/your-username" + }, + "nodeless": { + "baseURL": "https://nodeless.io", + "storeId": "your-nodeless-io-store-id" + }, + "opennode": { + "baseURL": "api.opennode.com", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/opennode" + }, + "nwc": { + "invoiceExpirySeconds": 900, + "replyTimeoutMs": 10000 + } + }, + "nip05": { + "mode": "disabled", + "verifyExpiration": 604800000, + "verifyUpdateFrequency": 86400000, + "maxConsecutiveFailures": 20, + "domainWhitelist": [], + "domainBlacklist": [] + }, + "nip42": { + "authRequired": false, + "sessionExpirySeconds": 0, + "restrictedReads": { + "enabled": false, + "kinds": [ + 4, + 1059 + ] + } + }, + "nip43": { + "enabled": false, + "inviteCodeExpirySeconds": 600, + "defaultMaxUses": 1, + "allowInviteRequests": false, + "inviteRequestWhitelist": [] + }, + "nip45": { + "enabled": true + }, + "nip50": { + "enabled": false, + "language": "simple", + "maxQueryLength": 256 + }, + "nip66": { + "enabled": false, + "probeIntervalSeconds": 3600, + "timeouts": { + "dnsMs": 10000, + "tlsMs": 10000, + "wsRttMs": 10000, + "nip11Ms": 10000 + }, + "targets": [], + "dnsCacheTtlSeconds": 300 + }, + "wot": { + "enabled": false, + "seedPubkey": "", + "minimumFollowers": 1, + "maxDepth": 2, + "refreshIntervalHours": 24 + }, + "nip56": { + "enabled": false, + "trustedModerators": [], + "hideActionableReports": false + }, + "network": { + "maxPayloadSize": 524288, + "trustedProxies": [ + "127.0.0.1", + "::ffff:127.0.0.1", + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "workers": { + "count": 0 + }, + "eventStore": { + "backend": "postgres" + }, + "mirroring": { + "static": [] + }, + "dvm": { + "workers": [] + }, + "limits": { + "rateLimiter": { + "strategy": "ewma" + }, + "invoice": { + "rateLimits": [ + { + "period": 60000, + "rate": 12 + }, + { + "period": 3600000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "invite": { + "rateLimits": [ + { + "description": "5 invite requests per hour per pubkey", + "period": 3600000, + "rate": 5 + } + ] + }, + "admissionCheck": { + "rateLimits": [ + { + "description": "30 admission checks/min or 1 check every 2 seconds", + "period": 60000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "admin": { + "rateLimits": [ + { + "description": "30 admin requests/min", + "period": 60000, + "rate": 30 + } + ], + "loginRateLimits": [ + { + "description": "10 login attempts per 15 minutes", + "period": 900000, + "rate": 10 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "connection": { + "rateLimits": [ + { + "period": 1000, + "rate": 12 + }, + { + "period": 60000, + "rate": 48 + } + ], + "ipWhitelist": [ + "127.0.0.1", + "::1", + "::ffff:127.0.0.1" + ] + }, + "event": { + "retention": { + "maxDays": -1, + "kind": { + "whitelist": [ + 62 + ] + }, + "pubkey": { + "whitelist": [] + } + }, + "eventId": { + "minLeadingZeroBits": 0 + }, + "pow": { + "enabled": false, + "floorBits": 0, + "ceilingBits": 24, + "targetEventsPerSecond": 50, + "periodMs": 60000, + "wotThresholds": [] + }, + "kind": { + "whitelist": [], + "blacklist": [] + }, + "pubkey": { + "minBalance": 0, + "minLeadingZeroBits": 0, + "whitelist": [], + "blacklist": [] + }, + "createdAt": { + "maxPositiveDelta": 900, + "maxNegativeDelta": 0 + }, + "content": [ + { + "description": "64 KB for event kind ranges 0-10 and 40-49", + "maxLength": 102400, + "kinds": [ + [ + 0, + 10 + ], + [ + 40, + 49 + ] + ] + }, + { + "description": "96 KB for event kind ranges 11-39 and 50-max", + "maxLength": 102400, + "kinds": [ + [ + 11, + 39 + ], + [ + 50, + 9007199254740991 + ] + ] + } + ], + "rateLimits": [ + { + "description": "6 events/min for event kinds 0, 3, 40 and 41", + "kinds": [ + 0, + 3, + 40, + 41 + ], + "period": 60000, + "rate": 6 + }, + { + "description": "12 events/min for event kinds 1, 2, 4 and 42", + "kinds": [ + 1, + 2, + 4, + 42 + ], + "period": 60000, + "rate": 12 + }, + { + "description": "30 events/min for event kind ranges 5-7 and 43-49", + "kinds": [ + [ + 5, + 7 + ], + [ + 43, + 49 + ] + ], + "period": 60000, + "rate": 30 + }, + { + "description": "24 events/min for replaceable events and parameterized replaceable events", + "kinds": [ + [ + 10000, + 19999 + ], + [ + 30000, + 39999 + ] + ], + "period": 60000, + "rate": 24 + }, + { + "description": "60 events/min for Marmot group events (kind 445)", + "kinds": [ + 445 + ], + "period": 60000, + "rate": 60 + }, + { + "description": "60 events/min for ephemeral events", + "kinds": [ + [ + 20000, + 29999 + ] + ], + "period": 60000, + "rate": 60 + }, + { + "description": "720 events/hour for all events", + "period": 3600000, + "rate": 720 + } + ], + "whitelists": { + "pubkeys": [], + "ipAddresses": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "client": { + "subscription": { + "maxSubscriptions": 10, + "maxFilters": 10, + "maxFilterValues": 2500, + "maxSubscriptionIdLength": 256, + "maxLimit": 5000, + "minPrefixLength": 4 + } + }, + "message": { + "rateLimits": [ + { + "period": 60000, + "rate": 0 + } + ], + "ipWhitelist": [] + } + }, + "admin": { + "enabled": false, + "sessionTtlSeconds": 86400, + "nip98": { + "enabled": false, + "allowedPubkeys": [], + "maxSkewSeconds": 60 + }, + "notifications": { + "enabled": false, + "targets": [], + "events": { + "admission.invoice.created": true, + "admission.invoice.paid": true, + "admission.invoice.failed": true, + "settings.changed": true, + "relay.restarted": false + }, + "retry": { + "maxAttempts": 5, + "baseDelayMs": 1000 + }, + "deliveryLogRetentionDays": 30 + } + } + }, + "steps": [ + { + "connect": true, + "expect": [ + "[\"AUTH\",\"{{challenge}}\"]" + ] + }, + { + "send": { + "text": "[\"REQ\",\"s1\",{\"kinds\":[1]}]" + }, + "expect": [ + "[\"CLOSED\",\"s1\",\"rate-limited: slow down\",\"60000\"]" + ] + }, + { + "send": { + "text": "[\"COUNT\",\"c1\",{\"kinds\":[1]}]" + }, + "expect": [ + "[\"CLOSED\",\"c1\",\"rate-limited: slow down\",\"60000\"]" + ] + }, + { + "send": { + "text": "[\"EVENT\",{\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"kind\":1,\"created_at\":1700000000,\"content\":\"wire-session\",\"tags\":[],\"id\":\"d73cc9e9e7bbfb8fbc3ba0ba9b66a5175d1161e7d3fb85c2d72aa2ef33d0edd1\",\"sig\":\"89fc957335b6d7ce82f561d4441881c7401019e830214296a63da7626b16331968c1cc6f70b0176cfe06c6d4ddfb4e90dd1c85aad877845ca21bbded25db895f\"}]" + }, + "expect": [ + "[\"NOTICE\",\"rate limited\"]" + ] + }, + { + "send": { + "text": "[\"REQ\",\"s2\",{\"kinds\":[1]}]" + }, + "expect": [ + "[\"CLOSED\",\"s2\",\"rate-limited: slow down\",\"60000\"]" + ] + }, + { + "send": { + "text": "[\"CLOSE\",\"s1\"]" + }, + "expect": [ + "[\"NOTICE\",\"rate limited\"]" + ] + } + ], + "relay_secret": "683c29961bc5247d177a5019710ee3b776fb07ef636d834b848b4f726ace3f51", + "random": { + "challenge": "1ed3d13dd216321a239ffe8412b77c16ccf196e859c6593750499dec3a66fd7f", + "invite_code": null + } + }, + { + "id": "rate-limit-event", + "challenge": "gN21iE_tEUutVn7FLiyGAGOKiRdYxf7YMcB-7UXu-aQ", + "clock": { + "start_ms": 1700000000000 + }, + "seed": 20261023, + "settings": { + "info": { + "relay_url": "wss://nostream.your-domain.com", + "name": "nostream.your-domain.com", + "description": "A nostr relay written in Typescript.", + "banner": "https://nostream.your-domain.com/banner.png", + "icon": "https://nostream.your-domain.com/icon.png", + "pubkey": "replace-with-your-pubkey-in-hex", + "contact": "mailto:operator@your-domain.com", + "terms_of_service": "https://nostream.your-domain.com/terms" + }, + "payments": { + "enabled": false, + "processor": "zebedee", + "feeSchedules": { + "admission": [ + { + "enabled": false, + "description": "Admission fee charged per public key in msats (1000 msats = 1 satoshi)", + "amount": 1000000, + "whitelists": { + "pubkeys": [ + "replace-with-your-pubkey-in-hex" + ], + "event_kinds": [ + 9735 + ] + } + } + ] + }, + "invoiceExpirySeconds": 86400 + }, + "paymentsProcessors": { + "zebedee": { + "baseURL": "https://api.zebedee.io/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/zebedee", + "ipWhitelist": [ + "3.225.112.64", + "::ffff:3.225.112.64" + ] + }, + "lnbits": { + "baseURL": "https://lnbits.your-domain.com/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/lnbits" + }, + "lnurl": { + "invoiceURL": "https://getalby.com/lnurlp/your-username" + }, + "nodeless": { + "baseURL": "https://nodeless.io", + "storeId": "your-nodeless-io-store-id" + }, + "opennode": { + "baseURL": "api.opennode.com", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/opennode" + }, + "nwc": { + "invoiceExpirySeconds": 900, + "replyTimeoutMs": 10000 + } + }, + "nip05": { + "mode": "disabled", + "verifyExpiration": 604800000, + "verifyUpdateFrequency": 86400000, + "maxConsecutiveFailures": 20, + "domainWhitelist": [], + "domainBlacklist": [] + }, + "nip42": { + "authRequired": false, + "sessionExpirySeconds": 0, + "restrictedReads": { + "enabled": false, + "kinds": [ + 4, + 1059 + ] + } + }, + "nip43": { + "enabled": false, + "inviteCodeExpirySeconds": 600, + "defaultMaxUses": 1, + "allowInviteRequests": false, + "inviteRequestWhitelist": [] + }, + "nip45": { + "enabled": true + }, + "nip50": { + "enabled": false, + "language": "simple", + "maxQueryLength": 256 + }, + "nip66": { + "enabled": false, + "probeIntervalSeconds": 3600, + "timeouts": { + "dnsMs": 10000, + "tlsMs": 10000, + "wsRttMs": 10000, + "nip11Ms": 10000 + }, + "targets": [], + "dnsCacheTtlSeconds": 300 + }, + "wot": { + "enabled": false, + "seedPubkey": "", + "minimumFollowers": 1, + "maxDepth": 2, + "refreshIntervalHours": 24 + }, + "nip56": { + "enabled": false, + "trustedModerators": [], + "hideActionableReports": false + }, + "network": { + "maxPayloadSize": 524288, + "trustedProxies": [ + "127.0.0.1", + "::ffff:127.0.0.1", + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "workers": { + "count": 0 + }, + "eventStore": { + "backend": "postgres" + }, + "mirroring": { + "static": [] + }, + "dvm": { + "workers": [] + }, + "limits": { + "rateLimiter": { + "strategy": "ewma" + }, + "invoice": { + "rateLimits": [ + { + "period": 60000, + "rate": 12 + }, + { + "period": 3600000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "invite": { + "rateLimits": [ + { + "description": "5 invite requests per hour per pubkey", + "period": 3600000, + "rate": 5 + } + ] + }, + "admissionCheck": { + "rateLimits": [ + { + "description": "30 admission checks/min or 1 check every 2 seconds", + "period": 60000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "admin": { + "rateLimits": [ + { + "description": "30 admin requests/min", + "period": 60000, + "rate": 30 + } + ], + "loginRateLimits": [ + { + "description": "10 login attempts per 15 minutes", + "period": 900000, + "rate": 10 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "connection": { + "rateLimits": [ + { + "period": 1000, + "rate": 12 + }, + { + "period": 60000, + "rate": 48 + } + ], + "ipWhitelist": [ + "127.0.0.1", + "::1", + "::ffff:127.0.0.1" + ] + }, + "event": { + "retention": { + "maxDays": -1, + "kind": { + "whitelist": [ + 62 + ] + }, + "pubkey": { + "whitelist": [] + } + }, + "eventId": { + "minLeadingZeroBits": 0 + }, + "pow": { + "enabled": false, + "floorBits": 0, + "ceilingBits": 24, + "targetEventsPerSecond": 50, + "periodMs": 60000, + "wotThresholds": [] + }, + "kind": { + "whitelist": [], + "blacklist": [] + }, + "pubkey": { + "minBalance": 0, + "minLeadingZeroBits": 0, + "whitelist": [], + "blacklist": [] + }, + "createdAt": { + "maxPositiveDelta": 900, + "maxNegativeDelta": 0 + }, + "content": [ + { + "description": "64 KB for event kind ranges 0-10 and 40-49", + "maxLength": 102400, + "kinds": [ + [ + 0, + 10 + ], + [ + 40, + 49 + ] + ] + }, + { + "description": "96 KB for event kind ranges 11-39 and 50-max", + "maxLength": 102400, + "kinds": [ + [ + 11, + 39 + ], + [ + 50, + 9007199254740991 + ] + ] + } + ], + "rateLimits": [ + { + "period": 60000, + "rate": 0 + } + ], + "whitelists": { + "pubkeys": [], + "ipAddresses": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "client": { + "subscription": { + "maxSubscriptions": 10, + "maxFilters": 10, + "maxFilterValues": 2500, + "maxSubscriptionIdLength": 256, + "maxLimit": 5000, + "minPrefixLength": 4 + } + }, + "message": { + "rateLimits": [ + { + "description": "240 raw messages/min", + "period": 60000, + "rate": 240 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "admin": { + "enabled": false, + "sessionTtlSeconds": 86400, + "nip98": { + "enabled": false, + "allowedPubkeys": [], + "maxSkewSeconds": 60 + }, + "notifications": { + "enabled": false, + "targets": [], + "events": { + "admission.invoice.created": true, + "admission.invoice.paid": true, + "admission.invoice.failed": true, + "settings.changed": true, + "relay.restarted": false + }, + "retry": { + "maxAttempts": 5, + "baseDelayMs": 1000 + }, + "deliveryLogRetentionDays": 30 + } + } + }, + "steps": [ + { + "connect": true, + "expect": [ + "[\"AUTH\",\"{{challenge}}\"]" + ] + }, + { + "send": { + "text": "[\"EVENT\",{\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"kind\":1,\"created_at\":1700000000,\"content\":\"limited\",\"tags\":[],\"id\":\"37541bd6338deecadcb63626443d5101c898f5965037f0c50d935deb343f670f\",\"sig\":\"866912950ff21314dadad4a51d04dd51a353f1c227b59d31eb0de8052fc26cc2fa38c049e370794f01dccd24dfe24446280fe14467ee278f64d4bda67a066c2b\"}]" + }, + "expect": [ + "[\"OK\",\"37541bd6338deecadcb63626443d5101c898f5965037f0c50d935deb343f670f\",false,\"rate-limited: slow down\",\"60000\"]" + ] + }, + { + "send": { + "text": "[\"EVENT\",{\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"kind\":1,\"created_at\":1700000000,\"content\":\"again\",\"tags\":[],\"id\":\"40da3350d508379eb1727ba23ebb018532779fc11798cec3be2067bb45731260\",\"sig\":\"f8b7a8da26e291b1371e0cb25fac6c48f0d6c45b6d71d2104cf1aad242ec3cc8a8ea696b8915638888082c776c490eae1366635e206cdbfb45ed092ffb6d4522\"}]" + }, + "expect": [ + "[\"OK\",\"40da3350d508379eb1727ba23ebb018532779fc11798cec3be2067bb45731260\",false,\"rate-limited: slow down\",\"60000\"]" + ] + } + ], + "relay_secret": "683c29961bc5247d177a5019710ee3b776fb07ef636d834b848b4f726ace3f51", + "random": { + "challenge": "80ddb5884fed114bad567ec52e2c8600638a891758c5fed831c07eed45eef9a4", + "invite_code": null + } + }, + { + "id": "whitelists", + "challenge": "LwCU_PnhVGNb53KRa9XemU9i0O3T9wjAmoJyThwgdEM", + "clock": { + "start_ms": 1700000000000 + }, + "seed": 20261023, + "settings": { + "info": { + "relay_url": "wss://nostream.your-domain.com", + "name": "nostream.your-domain.com", + "description": "A nostr relay written in Typescript.", + "banner": "https://nostream.your-domain.com/banner.png", + "icon": "https://nostream.your-domain.com/icon.png", + "pubkey": "replace-with-your-pubkey-in-hex", + "contact": "mailto:operator@your-domain.com", + "terms_of_service": "https://nostream.your-domain.com/terms" + }, + "payments": { + "enabled": false, + "processor": "zebedee", + "feeSchedules": { + "admission": [ + { + "enabled": false, + "description": "Admission fee charged per public key in msats (1000 msats = 1 satoshi)", + "amount": 1000000, + "whitelists": { + "pubkeys": [ + "replace-with-your-pubkey-in-hex" + ], + "event_kinds": [ + 9735 + ] + } + } + ] + }, + "invoiceExpirySeconds": 86400 + }, + "paymentsProcessors": { + "zebedee": { + "baseURL": "https://api.zebedee.io/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/zebedee", + "ipWhitelist": [ + "3.225.112.64", + "::ffff:3.225.112.64" + ] + }, + "lnbits": { + "baseURL": "https://lnbits.your-domain.com/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/lnbits" + }, + "lnurl": { + "invoiceURL": "https://getalby.com/lnurlp/your-username" + }, + "nodeless": { + "baseURL": "https://nodeless.io", + "storeId": "your-nodeless-io-store-id" + }, + "opennode": { + "baseURL": "api.opennode.com", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/opennode" + }, + "nwc": { + "invoiceExpirySeconds": 900, + "replyTimeoutMs": 10000 + } + }, + "nip05": { + "mode": "disabled", + "verifyExpiration": 604800000, + "verifyUpdateFrequency": 86400000, + "maxConsecutiveFailures": 20, + "domainWhitelist": [], + "domainBlacklist": [] + }, + "nip42": { + "authRequired": false, + "sessionExpirySeconds": 0, + "restrictedReads": { + "enabled": false, + "kinds": [ + 4, + 1059 + ] + } + }, + "nip43": { + "enabled": false, + "inviteCodeExpirySeconds": 600, + "defaultMaxUses": 1, + "allowInviteRequests": false, + "inviteRequestWhitelist": [] + }, + "nip45": { + "enabled": true + }, + "nip50": { + "enabled": false, + "language": "simple", + "maxQueryLength": 256 + }, + "nip66": { + "enabled": false, + "probeIntervalSeconds": 3600, + "timeouts": { + "dnsMs": 10000, + "tlsMs": 10000, + "wsRttMs": 10000, + "nip11Ms": 10000 + }, + "targets": [], + "dnsCacheTtlSeconds": 300 + }, + "wot": { + "enabled": false, + "seedPubkey": "", + "minimumFollowers": 1, + "maxDepth": 2, + "refreshIntervalHours": 24 + }, + "nip56": { + "enabled": false, + "trustedModerators": [], + "hideActionableReports": false + }, + "network": { + "maxPayloadSize": 524288, + "trustedProxies": [ + "127.0.0.1", + "::ffff:127.0.0.1", + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "workers": { + "count": 0 + }, + "eventStore": { + "backend": "postgres" + }, + "mirroring": { + "static": [] + }, + "dvm": { + "workers": [] + }, + "limits": { + "rateLimiter": { + "strategy": "ewma" + }, + "invoice": { + "rateLimits": [ + { + "period": 60000, + "rate": 12 + }, + { + "period": 3600000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "invite": { + "rateLimits": [ + { + "description": "5 invite requests per hour per pubkey", + "period": 3600000, + "rate": 5 + } + ] + }, + "admissionCheck": { + "rateLimits": [ + { + "description": "30 admission checks/min or 1 check every 2 seconds", + "period": 60000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "admin": { + "rateLimits": [ + { + "description": "30 admin requests/min", + "period": 60000, + "rate": 30 + } + ], + "loginRateLimits": [ + { + "description": "10 login attempts per 15 minutes", + "period": 900000, + "rate": 10 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "connection": { + "rateLimits": [ + { + "period": 1000, + "rate": 12 + }, + { + "period": 60000, + "rate": 48 + } + ], + "ipWhitelist": [ + "127.0.0.1", + "::1", + "::ffff:127.0.0.1" + ] + }, + "event": { + "retention": { + "maxDays": -1, + "kind": { + "whitelist": [ + 62 + ] + }, + "pubkey": { + "whitelist": [] + } + }, + "eventId": { + "minLeadingZeroBits": 0 + }, + "pow": { + "enabled": false, + "floorBits": 0, + "ceilingBits": 24, + "targetEventsPerSecond": 50, + "periodMs": 60000, + "wotThresholds": [] + }, + "kind": { + "whitelist": [], + "blacklist": [] + }, + "pubkey": { + "minBalance": 0, + "minLeadingZeroBits": 0, + "whitelist": [], + "blacklist": [] + }, + "createdAt": { + "maxPositiveDelta": 900, + "maxNegativeDelta": 0 + }, + "content": [ + { + "description": "64 KB for event kind ranges 0-10 and 40-49", + "maxLength": 102400, + "kinds": [ + [ + 0, + 10 + ], + [ + 40, + 49 + ] + ] + }, + { + "description": "96 KB for event kind ranges 11-39 and 50-max", + "maxLength": 102400, + "kinds": [ + [ + 11, + 39 + ], + [ + 50, + 9007199254740991 + ] + ] + } + ], + "rateLimits": [ + { + "period": 60000, + "rate": 0 + } + ], + "whitelists": { + "pubkeys": [ + "8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271" + ], + "ipAddresses": [] + } + }, + "client": { + "subscription": { + "maxSubscriptions": 10, + "maxFilters": 10, + "maxFilterValues": 2500, + "maxSubscriptionIdLength": 256, + "maxLimit": 5000, + "minPrefixLength": 4 + } + }, + "message": { + "rateLimits": [ + { + "period": 60000, + "rate": 0 + } + ], + "ipWhitelist": [ + "127.0.0.1", + "::1", + "::ffff:127.0.0.1" + ] + } + }, + "admin": { + "enabled": false, + "sessionTtlSeconds": 86400, + "nip98": { + "enabled": false, + "allowedPubkeys": [], + "maxSkewSeconds": 60 + }, + "notifications": { + "enabled": false, + "targets": [], + "events": { + "admission.invoice.created": true, + "admission.invoice.paid": true, + "admission.invoice.failed": true, + "settings.changed": true, + "relay.restarted": false + }, + "retry": { + "maxAttempts": 5, + "baseDelayMs": 1000 + }, + "deliveryLogRetentionDays": 30 + } + } + }, + "steps": [ + { + "connect": true, + "expect": [ + "[\"AUTH\",\"{{challenge}}\"]" + ] + }, + { + "send": { + "text": "[\"REQ\",\"w1\",{\"kinds\":[1]}]" + }, + "expect": [ + "[\"EOSE\",\"w1\"]" + ] + }, + { + "send": { + "text": "[\"EVENT\",{\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"kind\":1,\"created_at\":1700000000,\"content\":\"whitelisted\",\"tags\":[],\"id\":\"17e604cc50738b82c83f0d6a9eee091d7720b57e759159de39fafc16b1c5878d\",\"sig\":\"52454cd77b16ef0a431e3da0eb9b27ab6fd3ebb66d6ed7816b11bcd3835868684392c06b46ace41004b703d6d137e2e9814efcbb8741a28e7b3cce40adbfdc6f\"}]" + }, + "expect": [ + "[\"OK\",\"17e604cc50738b82c83f0d6a9eee091d7720b57e759159de39fafc16b1c5878d\",true,\"\"]" + ] + }, + { + "send": { + "text": "[\"EVENT\",{\"pubkey\":\"c5f791b034c3bfe65e142b023ae8d7e2a288b9ed6ac5b8a81521430f503ff66b\",\"kind\":1,\"created_at\":1700000000,\"content\":\"not whitelisted\",\"tags\":[],\"id\":\"1f24050ac0bf302d0b354f1af5bfc749b248ec6025f5dd6ddf51b8105c6b211a\",\"sig\":\"757c24731d485f54b987f98904fba5061c8e29e7efd1c1bf24fffe7f94d4c7d219ad73aac8204c78644986cd45262b2a4510832a8e0295f4a7d85d7cb0449ab4\"}]" + }, + "expect": [ + "[\"EVENT\",\"w1\",{\"id\":\"17e604cc50738b82c83f0d6a9eee091d7720b57e759159de39fafc16b1c5878d\",\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"created_at\":1700000000,\"kind\":1,\"tags\":[],\"content\":\"whitelisted\",\"sig\":\"52454cd77b16ef0a431e3da0eb9b27ab6fd3ebb66d6ed7816b11bcd3835868684392c06b46ace41004b703d6d137e2e9814efcbb8741a28e7b3cce40adbfdc6f\"}]" + ] + } + ], + "relay_secret": "683c29961bc5247d177a5019710ee3b776fb07ef636d834b848b4f726ace3f51", + "random": { + "challenge": "2f0094fcf9e154635be772916bd5de994f62d0edd3f708c09a82724e1c207443", + "invite_code": null + } + }, + { + "id": "req-auth-required", + "challenge": "bqQUDkSXi74dltt6R5sVJWh-gnRjom_ZbeNkua7eYDI", + "clock": { + "start_ms": 1700000000000 + }, + "seed": 20261023, + "settings": { + "info": { + "relay_url": "wss://nostream.your-domain.com", + "name": "nostream.your-domain.com", + "description": "A nostr relay written in Typescript.", + "banner": "https://nostream.your-domain.com/banner.png", + "icon": "https://nostream.your-domain.com/icon.png", + "pubkey": "replace-with-your-pubkey-in-hex", + "contact": "mailto:operator@your-domain.com", + "terms_of_service": "https://nostream.your-domain.com/terms" + }, + "payments": { + "enabled": false, + "processor": "zebedee", + "feeSchedules": { + "admission": [ + { + "enabled": false, + "description": "Admission fee charged per public key in msats (1000 msats = 1 satoshi)", + "amount": 1000000, + "whitelists": { + "pubkeys": [ + "replace-with-your-pubkey-in-hex" + ], + "event_kinds": [ + 9735 + ] + } + } + ] + }, + "invoiceExpirySeconds": 86400 + }, + "paymentsProcessors": { + "zebedee": { + "baseURL": "https://api.zebedee.io/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/zebedee", + "ipWhitelist": [ + "3.225.112.64", + "::ffff:3.225.112.64" + ] + }, + "lnbits": { + "baseURL": "https://lnbits.your-domain.com/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/lnbits" + }, + "lnurl": { + "invoiceURL": "https://getalby.com/lnurlp/your-username" + }, + "nodeless": { + "baseURL": "https://nodeless.io", + "storeId": "your-nodeless-io-store-id" + }, + "opennode": { + "baseURL": "api.opennode.com", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/opennode" + }, + "nwc": { + "invoiceExpirySeconds": 900, + "replyTimeoutMs": 10000 + } + }, + "nip05": { + "mode": "disabled", + "verifyExpiration": 604800000, + "verifyUpdateFrequency": 86400000, + "maxConsecutiveFailures": 20, + "domainWhitelist": [], + "domainBlacklist": [] + }, + "nip42": { + "authRequired": false, + "sessionExpirySeconds": 0, + "restrictedReads": { + "enabled": true, + "kinds": [ + 4 + ] + } + }, + "nip43": { + "enabled": false, + "inviteCodeExpirySeconds": 600, + "defaultMaxUses": 1, + "allowInviteRequests": false, + "inviteRequestWhitelist": [] + }, + "nip45": { + "enabled": true + }, + "nip50": { + "enabled": false, + "language": "simple", + "maxQueryLength": 256 + }, + "nip66": { + "enabled": false, + "probeIntervalSeconds": 3600, + "timeouts": { + "dnsMs": 10000, + "tlsMs": 10000, + "wsRttMs": 10000, + "nip11Ms": 10000 + }, + "targets": [], + "dnsCacheTtlSeconds": 300 + }, + "wot": { + "enabled": false, + "seedPubkey": "", + "minimumFollowers": 1, + "maxDepth": 2, + "refreshIntervalHours": 24 + }, + "nip56": { + "enabled": false, + "trustedModerators": [], + "hideActionableReports": false + }, + "network": { + "maxPayloadSize": 524288, + "trustedProxies": [ + "127.0.0.1", + "::ffff:127.0.0.1", + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "workers": { + "count": 0 + }, + "eventStore": { + "backend": "postgres" + }, + "mirroring": { + "static": [] + }, + "dvm": { + "workers": [] + }, + "limits": { + "rateLimiter": { + "strategy": "ewma" + }, + "invoice": { + "rateLimits": [ + { + "period": 60000, + "rate": 12 + }, + { + "period": 3600000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "invite": { + "rateLimits": [ + { + "description": "5 invite requests per hour per pubkey", + "period": 3600000, + "rate": 5 + } + ] + }, + "admissionCheck": { + "rateLimits": [ + { + "description": "30 admission checks/min or 1 check every 2 seconds", + "period": 60000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "admin": { + "rateLimits": [ + { + "description": "30 admin requests/min", + "period": 60000, + "rate": 30 + } + ], + "loginRateLimits": [ + { + "description": "10 login attempts per 15 minutes", + "period": 900000, + "rate": 10 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "connection": { + "rateLimits": [ + { + "period": 1000, + "rate": 12 + }, + { + "period": 60000, + "rate": 48 + } + ], + "ipWhitelist": [ + "127.0.0.1", + "::1", + "::ffff:127.0.0.1" + ] + }, + "event": { + "retention": { + "maxDays": -1, + "kind": { + "whitelist": [ + 62 + ] + }, + "pubkey": { + "whitelist": [] + } + }, + "eventId": { + "minLeadingZeroBits": 0 + }, + "pow": { + "enabled": false, + "floorBits": 0, + "ceilingBits": 24, + "targetEventsPerSecond": 50, + "periodMs": 60000, + "wotThresholds": [] + }, + "kind": { + "whitelist": [], + "blacklist": [] + }, + "pubkey": { + "minBalance": 0, + "minLeadingZeroBits": 0, + "whitelist": [], + "blacklist": [] + }, + "createdAt": { + "maxPositiveDelta": 900, + "maxNegativeDelta": 0 + }, + "content": [ + { + "description": "64 KB for event kind ranges 0-10 and 40-49", + "maxLength": 102400, + "kinds": [ + [ + 0, + 10 + ], + [ + 40, + 49 + ] + ] + }, + { + "description": "96 KB for event kind ranges 11-39 and 50-max", + "maxLength": 102400, + "kinds": [ + [ + 11, + 39 + ], + [ + 50, + 9007199254740991 + ] + ] + } + ], + "rateLimits": [ + { + "description": "6 events/min for event kinds 0, 3, 40 and 41", + "kinds": [ + 0, + 3, + 40, + 41 + ], + "period": 60000, + "rate": 6 + }, + { + "description": "12 events/min for event kinds 1, 2, 4 and 42", + "kinds": [ + 1, + 2, + 4, + 42 + ], + "period": 60000, + "rate": 12 + }, + { + "description": "30 events/min for event kind ranges 5-7 and 43-49", + "kinds": [ + [ + 5, + 7 + ], + [ + 43, + 49 + ] + ], + "period": 60000, + "rate": 30 + }, + { + "description": "24 events/min for replaceable events and parameterized replaceable events", + "kinds": [ + [ + 10000, + 19999 + ], + [ + 30000, + 39999 + ] + ], + "period": 60000, + "rate": 24 + }, + { + "description": "60 events/min for Marmot group events (kind 445)", + "kinds": [ + 445 + ], + "period": 60000, + "rate": 60 + }, + { + "description": "60 events/min for ephemeral events", + "kinds": [ + [ + 20000, + 29999 + ] + ], + "period": 60000, + "rate": 60 + }, + { + "description": "720 events/hour for all events", + "period": 3600000, + "rate": 720 + } + ], + "whitelists": { + "pubkeys": [], + "ipAddresses": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "client": { + "subscription": { + "maxSubscriptions": 10, + "maxFilters": 10, + "maxFilterValues": 2500, + "maxSubscriptionIdLength": 256, + "maxLimit": 5000, + "minPrefixLength": 4 + } + }, + "message": { + "rateLimits": [ + { + "description": "240 raw messages/min", + "period": 60000, + "rate": 240 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "admin": { + "enabled": false, + "sessionTtlSeconds": 86400, + "nip98": { + "enabled": false, + "allowedPubkeys": [], + "maxSkewSeconds": 60 + }, + "notifications": { + "enabled": false, + "targets": [], + "events": { + "admission.invoice.created": true, + "admission.invoice.paid": true, + "admission.invoice.failed": true, + "settings.changed": true, + "relay.restarted": false + }, + "retry": { + "maxAttempts": 5, + "baseDelayMs": 1000 + }, + "deliveryLogRetentionDays": 30 + } + } + }, + "steps": [ + { + "connect": true, + "expect": [ + "[\"AUTH\",\"{{challenge}}\"]" + ] + }, + { + "send": { + "text": "[\"REQ\",\"r1\",{\"kinds\":[4]}]" + }, + "expect": [ + "[\"CLOSED\",\"r1\",\"auth-required: authentication is required to request these event kinds\"]" + ] + }, + { + "send": { + "text": "[\"AUTH\",{\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"kind\":22242,\"created_at\":1700000000,\"content\":\"wire-session\",\"tags\":[[\"relay\",\"wss://nostream.your-domain.com\"],[\"challenge\",\"{{challenge}}\"]],\"id\":\"ff5cbf93856f43f91ce1ebe84d39c6a27f955f93934a26a4f8dce322945f2c43\",\"sig\":\"c7bde8ef262f656027879494bf9e3d57209a48fc8ef81a1fe635e4a538774a40ba1726068edb0c84b5fceff91e47e2fdf88959d7869b81f39092765eb19643de\"}]" + }, + "expect": [ + "[\"OK\",\"ff5cbf93856f43f91ce1ebe84d39c6a27f955f93934a26a4f8dce322945f2c43\",true,\"\"]" + ] + }, + { + "send": { + "text": "[\"REQ\",\"r2\",{\"kinds\":[4]}]" + }, + "expect": [ + "[\"EOSE\",\"r2\"]" + ] + } + ], + "relay_secret": "683c29961bc5247d177a5019710ee3b776fb07ef636d834b848b4f726ace3f51", + "random": { + "challenge": "6ea4140e44978bbe1d96db7a479b1525687e827463a26fd96de364b9aede6032", + "invite_code": null + } + }, + { + "id": "req-close-abort", + "challenge": "Ivx6oYdezVmX9Z9zCYZMCiH6l_kbe96ysPYqD2Nd5Yc", + "clock": { + "start_ms": 1700000000000 + }, + "seed": 20261023, + "settings": { + "info": { + "relay_url": "wss://nostream.your-domain.com", + "name": "nostream.your-domain.com", + "description": "A nostr relay written in Typescript.", + "banner": "https://nostream.your-domain.com/banner.png", + "icon": "https://nostream.your-domain.com/icon.png", + "pubkey": "replace-with-your-pubkey-in-hex", + "contact": "mailto:operator@your-domain.com", + "terms_of_service": "https://nostream.your-domain.com/terms" + }, + "payments": { + "enabled": false, + "processor": "zebedee", + "feeSchedules": { + "admission": [ + { + "enabled": false, + "description": "Admission fee charged per public key in msats (1000 msats = 1 satoshi)", + "amount": 1000000, + "whitelists": { + "pubkeys": [ + "replace-with-your-pubkey-in-hex" + ], + "event_kinds": [ + 9735 + ] + } + } + ] + }, + "invoiceExpirySeconds": 86400 + }, + "paymentsProcessors": { + "zebedee": { + "baseURL": "https://api.zebedee.io/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/zebedee", + "ipWhitelist": [ + "3.225.112.64", + "::ffff:3.225.112.64" + ] + }, + "lnbits": { + "baseURL": "https://lnbits.your-domain.com/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/lnbits" + }, + "lnurl": { + "invoiceURL": "https://getalby.com/lnurlp/your-username" + }, + "nodeless": { + "baseURL": "https://nodeless.io", + "storeId": "your-nodeless-io-store-id" + }, + "opennode": { + "baseURL": "api.opennode.com", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/opennode" + }, + "nwc": { + "invoiceExpirySeconds": 900, + "replyTimeoutMs": 10000 + } + }, + "nip05": { + "mode": "disabled", + "verifyExpiration": 604800000, + "verifyUpdateFrequency": 86400000, + "maxConsecutiveFailures": 20, + "domainWhitelist": [], + "domainBlacklist": [] + }, + "nip42": { + "authRequired": false, + "sessionExpirySeconds": 0, + "restrictedReads": { + "enabled": false, + "kinds": [ + 4, + 1059 + ] + } + }, + "nip43": { + "enabled": false, + "inviteCodeExpirySeconds": 600, + "defaultMaxUses": 1, + "allowInviteRequests": false, + "inviteRequestWhitelist": [] + }, + "nip45": { + "enabled": true + }, + "nip50": { + "enabled": false, + "language": "simple", + "maxQueryLength": 256 + }, + "nip66": { + "enabled": false, + "probeIntervalSeconds": 3600, + "timeouts": { + "dnsMs": 10000, + "tlsMs": 10000, + "wsRttMs": 10000, + "nip11Ms": 10000 + }, + "targets": [], + "dnsCacheTtlSeconds": 300 + }, + "wot": { + "enabled": false, + "seedPubkey": "", + "minimumFollowers": 1, + "maxDepth": 2, + "refreshIntervalHours": 24 + }, + "nip56": { + "enabled": false, + "trustedModerators": [], + "hideActionableReports": false + }, + "network": { + "maxPayloadSize": 524288, + "trustedProxies": [ + "127.0.0.1", + "::ffff:127.0.0.1", + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "workers": { + "count": 0 + }, + "eventStore": { + "backend": "postgres" + }, + "mirroring": { + "static": [] + }, + "dvm": { + "workers": [] + }, + "limits": { + "rateLimiter": { + "strategy": "ewma" + }, + "invoice": { + "rateLimits": [ + { + "period": 60000, + "rate": 12 + }, + { + "period": 3600000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "invite": { + "rateLimits": [ + { + "description": "5 invite requests per hour per pubkey", + "period": 3600000, + "rate": 5 + } + ] + }, + "admissionCheck": { + "rateLimits": [ + { + "description": "30 admission checks/min or 1 check every 2 seconds", + "period": 60000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "admin": { + "rateLimits": [ + { + "description": "30 admin requests/min", + "period": 60000, + "rate": 30 + } + ], + "loginRateLimits": [ + { + "description": "10 login attempts per 15 minutes", + "period": 900000, + "rate": 10 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "connection": { + "rateLimits": [ + { + "period": 1000, + "rate": 12 + }, + { + "period": 60000, + "rate": 48 + } + ], + "ipWhitelist": [ + "127.0.0.1", + "::1", + "::ffff:127.0.0.1" + ] + }, + "event": { + "retention": { + "maxDays": -1, + "kind": { + "whitelist": [ + 62 + ] + }, + "pubkey": { + "whitelist": [] + } + }, + "eventId": { + "minLeadingZeroBits": 0 + }, + "pow": { + "enabled": false, + "floorBits": 0, + "ceilingBits": 24, + "targetEventsPerSecond": 50, + "periodMs": 60000, + "wotThresholds": [] + }, + "kind": { + "whitelist": [], + "blacklist": [] + }, + "pubkey": { + "minBalance": 0, + "minLeadingZeroBits": 0, + "whitelist": [], + "blacklist": [] + }, + "createdAt": { + "maxPositiveDelta": 900, + "maxNegativeDelta": 0 + }, + "content": [ + { + "description": "64 KB for event kind ranges 0-10 and 40-49", + "maxLength": 102400, + "kinds": [ + [ + 0, + 10 + ], + [ + 40, + 49 + ] + ] + }, + { + "description": "96 KB for event kind ranges 11-39 and 50-max", + "maxLength": 102400, + "kinds": [ + [ + 11, + 39 + ], + [ + 50, + 9007199254740991 + ] + ] + } + ], + "rateLimits": [ + { + "description": "6 events/min for event kinds 0, 3, 40 and 41", + "kinds": [ + 0, + 3, + 40, + 41 + ], + "period": 60000, + "rate": 6 + }, + { + "description": "12 events/min for event kinds 1, 2, 4 and 42", + "kinds": [ + 1, + 2, + 4, + 42 + ], + "period": 60000, + "rate": 12 + }, + { + "description": "30 events/min for event kind ranges 5-7 and 43-49", + "kinds": [ + [ + 5, + 7 + ], + [ + 43, + 49 + ] + ], + "period": 60000, + "rate": 30 + }, + { + "description": "24 events/min for replaceable events and parameterized replaceable events", + "kinds": [ + [ + 10000, + 19999 + ], + [ + 30000, + 39999 + ] + ], + "period": 60000, + "rate": 24 + }, + { + "description": "60 events/min for Marmot group events (kind 445)", + "kinds": [ + 445 + ], + "period": 60000, + "rate": 60 + }, + { + "description": "60 events/min for ephemeral events", + "kinds": [ + [ + 20000, + 29999 + ] + ], + "period": 60000, + "rate": 60 + }, + { + "description": "720 events/hour for all events", + "period": 3600000, + "rate": 720 + } + ], + "whitelists": { + "pubkeys": [], + "ipAddresses": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "client": { + "subscription": { + "maxSubscriptions": 10, + "maxFilters": 10, + "maxFilterValues": 2500, + "maxSubscriptionIdLength": 256, + "maxLimit": 5000, + "minPrefixLength": 4 + } + }, + "message": { + "rateLimits": [ + { + "description": "240 raw messages/min", + "period": 60000, + "rate": 240 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "admin": { + "enabled": false, + "sessionTtlSeconds": 86400, + "nip98": { + "enabled": false, + "allowedPubkeys": [], + "maxSkewSeconds": 60 + }, + "notifications": { + "enabled": false, + "targets": [], + "events": { + "admission.invoice.created": true, + "admission.invoice.paid": true, + "admission.invoice.failed": true, + "settings.changed": true, + "relay.restarted": false + }, + "retry": { + "maxAttempts": 5, + "baseDelayMs": 1000 + }, + "deliveryLogRetentionDays": 30 + } + } + }, + "steps": [ + { + "connect": true, + "expect": [ + "[\"AUTH\",\"{{challenge}}\"]" + ] + }, + { + "send": { + "text": "[\"REQ\",\"c1\",{\"kinds\":[1]}]" + }, + "expect": [ + "[\"EOSE\",\"c1\"]" + ] + }, + { + "send": { + "text": "[\"CLOSE\",\"c1\"]" + }, + "expect": [] + }, + { + "send": { + "text": "[\"REQ\",\"c2\",{\"kinds\":[1]}]" + }, + "expect": [ + "[\"EOSE\",\"c2\"]" + ] + } + ], + "relay_secret": "683c29961bc5247d177a5019710ee3b776fb07ef636d834b848b4f726ace3f51", + "random": { + "challenge": "22fc7aa1875ecd5997f59f7309864c0a21fa97f91b7bdeb2b0f62a0f635de587", + "invite_code": null + } + }, + { + "id": "invite-mint", + "challenge": "eBHy4APoNR0Yb_Yrfm7y3Qzp4T2ycBa20fyiSlkUb7k", + "clock": { + "start_ms": 1700000000000 + }, + "seed": 20261023, + "settings": { + "info": { + "relay_url": "wss://nostream.your-domain.com", + "name": "nostream.your-domain.com", + "description": "A nostr relay written in Typescript.", + "banner": "https://nostream.your-domain.com/banner.png", + "icon": "https://nostream.your-domain.com/icon.png", + "pubkey": "608219ac222037d5914b0bec4441d5a913eafe1f14175ff18ee8fa08a87e085d", + "contact": "mailto:operator@your-domain.com", + "terms_of_service": "https://nostream.your-domain.com/terms", + "self": "608219ac222037d5914b0bec4441d5a913eafe1f14175ff18ee8fa08a87e085d" + }, + "payments": { + "enabled": false, + "processor": "zebedee", + "feeSchedules": { + "admission": [ + { + "enabled": false, + "description": "Admission fee charged per public key in msats (1000 msats = 1 satoshi)", + "amount": 1000000, + "whitelists": { + "pubkeys": [ + "replace-with-your-pubkey-in-hex" + ], + "event_kinds": [ + 9735 + ] + } + } + ] + }, + "invoiceExpirySeconds": 86400 + }, + "paymentsProcessors": { + "zebedee": { + "baseURL": "https://api.zebedee.io/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/zebedee", + "ipWhitelist": [ + "3.225.112.64", + "::ffff:3.225.112.64" + ] + }, + "lnbits": { + "baseURL": "https://lnbits.your-domain.com/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/lnbits" + }, + "lnurl": { + "invoiceURL": "https://getalby.com/lnurlp/your-username" + }, + "nodeless": { + "baseURL": "https://nodeless.io", + "storeId": "your-nodeless-io-store-id" + }, + "opennode": { + "baseURL": "api.opennode.com", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/opennode" + }, + "nwc": { + "invoiceExpirySeconds": 900, + "replyTimeoutMs": 10000 + } + }, + "nip05": { + "mode": "disabled", + "verifyExpiration": 604800000, + "verifyUpdateFrequency": 86400000, + "maxConsecutiveFailures": 20, + "domainWhitelist": [], + "domainBlacklist": [] + }, + "nip42": { + "authRequired": false, + "sessionExpirySeconds": 0, + "restrictedReads": { + "enabled": false, + "kinds": [ + 4, + 1059 + ] + } + }, + "nip43": { + "enabled": true, + "inviteCodeExpirySeconds": 600, + "defaultMaxUses": 1, + "allowInviteRequests": true, + "inviteRequestWhitelist": [] + }, + "nip45": { + "enabled": true + }, + "nip50": { + "enabled": false, + "language": "simple", + "maxQueryLength": 256 + }, + "nip66": { + "enabled": false, + "probeIntervalSeconds": 3600, + "timeouts": { + "dnsMs": 10000, + "tlsMs": 10000, + "wsRttMs": 10000, + "nip11Ms": 10000 + }, + "targets": [], + "dnsCacheTtlSeconds": 300 + }, + "wot": { + "enabled": false, + "seedPubkey": "", + "minimumFollowers": 1, + "maxDepth": 2, + "refreshIntervalHours": 24 + }, + "nip56": { + "enabled": false, + "trustedModerators": [], + "hideActionableReports": false + }, + "network": { + "maxPayloadSize": 524288, + "trustedProxies": [ + "127.0.0.1", + "::ffff:127.0.0.1", + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "workers": { + "count": 0 + }, + "eventStore": { + "backend": "postgres" + }, + "mirroring": { + "static": [] + }, + "dvm": { + "workers": [] + }, + "limits": { + "rateLimiter": { + "strategy": "ewma" + }, + "invoice": { + "rateLimits": [ + { + "period": 60000, + "rate": 12 + }, + { + "period": 3600000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "invite": { + "rateLimits": [ + { + "description": "5 invite requests per hour per pubkey", + "period": 3600000, + "rate": 5 + } + ] + }, + "admissionCheck": { + "rateLimits": [ + { + "description": "30 admission checks/min or 1 check every 2 seconds", + "period": 60000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "admin": { + "rateLimits": [ + { + "description": "30 admin requests/min", + "period": 60000, + "rate": 30 + } + ], + "loginRateLimits": [ + { + "description": "10 login attempts per 15 minutes", + "period": 900000, + "rate": 10 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "connection": { + "rateLimits": [ + { + "period": 1000, + "rate": 12 + }, + { + "period": 60000, + "rate": 48 + } + ], + "ipWhitelist": [ + "127.0.0.1", + "::1", + "::ffff:127.0.0.1" + ] + }, + "event": { + "retention": { + "maxDays": -1, + "kind": { + "whitelist": [ + 62 + ] + }, + "pubkey": { + "whitelist": [] + } + }, + "eventId": { + "minLeadingZeroBits": 0 + }, + "pow": { + "enabled": false, + "floorBits": 0, + "ceilingBits": 24, + "targetEventsPerSecond": 50, + "periodMs": 60000, + "wotThresholds": [] + }, + "kind": { + "whitelist": [], + "blacklist": [] + }, + "pubkey": { + "minBalance": 0, + "minLeadingZeroBits": 0, + "whitelist": [], + "blacklist": [] + }, + "createdAt": { + "maxPositiveDelta": 900, + "maxNegativeDelta": 0 + }, + "content": [ + { + "description": "64 KB for event kind ranges 0-10 and 40-49", + "maxLength": 102400, + "kinds": [ + [ + 0, + 10 + ], + [ + 40, + 49 + ] + ] + }, + { + "description": "96 KB for event kind ranges 11-39 and 50-max", + "maxLength": 102400, + "kinds": [ + [ + 11, + 39 + ], + [ + 50, + 9007199254740991 + ] + ] + } + ], + "rateLimits": [ + { + "description": "6 events/min for event kinds 0, 3, 40 and 41", + "kinds": [ + 0, + 3, + 40, + 41 + ], + "period": 60000, + "rate": 6 + }, + { + "description": "12 events/min for event kinds 1, 2, 4 and 42", + "kinds": [ + 1, + 2, + 4, + 42 + ], + "period": 60000, + "rate": 12 + }, + { + "description": "30 events/min for event kind ranges 5-7 and 43-49", + "kinds": [ + [ + 5, + 7 + ], + [ + 43, + 49 + ] + ], + "period": 60000, + "rate": 30 + }, + { + "description": "24 events/min for replaceable events and parameterized replaceable events", + "kinds": [ + [ + 10000, + 19999 + ], + [ + 30000, + 39999 + ] + ], + "period": 60000, + "rate": 24 + }, + { + "description": "60 events/min for Marmot group events (kind 445)", + "kinds": [ + 445 + ], + "period": 60000, + "rate": 60 + }, + { + "description": "60 events/min for ephemeral events", + "kinds": [ + [ + 20000, + 29999 + ] + ], + "period": 60000, + "rate": 60 + }, + { + "description": "720 events/hour for all events", + "period": 3600000, + "rate": 720 + } + ], + "whitelists": { + "pubkeys": [], + "ipAddresses": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "client": { + "subscription": { + "maxSubscriptions": 10, + "maxFilters": 10, + "maxFilterValues": 2500, + "maxSubscriptionIdLength": 256, + "maxLimit": 5000, + "minPrefixLength": 4 + } + }, + "message": { + "rateLimits": [ + { + "description": "240 raw messages/min", + "period": 60000, + "rate": 240 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "admin": { + "enabled": false, + "sessionTtlSeconds": 86400, + "nip98": { + "enabled": false, + "allowedPubkeys": [], + "maxSkewSeconds": 60 + }, + "notifications": { + "enabled": false, + "targets": [], + "events": { + "admission.invoice.created": true, + "admission.invoice.paid": true, + "admission.invoice.failed": true, + "settings.changed": true, + "relay.restarted": false + }, + "retry": { + "maxAttempts": 5, + "baseDelayMs": 1000 + }, + "deliveryLogRetentionDays": 30 + } + } + }, + "steps": [ + { + "connect": true, + "expect": [ + "[\"AUTH\",\"{{challenge}}\"]" + ] + }, + { + "send": { + "text": "[\"AUTH\",{\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"kind\":22242,\"created_at\":1700000000,\"content\":\"wire-session\",\"tags\":[[\"relay\",\"wss://nostream.your-domain.com\"],[\"challenge\",\"{{challenge}}\"]],\"id\":\"7b57ee36d89cc95e86b4f6d89b232325a096bc6de6104c8aed9d65e5ae9b16fe\",\"sig\":\"91ce6ace113ed1dd94cf921cb5c6ecd5e4e09b5a25a83420d49744352269b2aa72cbf9c5c9518ca682a210859cd3a76f48f69f46a28f41dc16f4fb1873160a5b\"}]" + }, + "expect": [ + "[\"OK\",\"7b57ee36d89cc95e86b4f6d89b232325a096bc6de6104c8aed9d65e5ae9b16fe\",true,\"\"]" + ] + }, + { + "send": { + "text": "[\"REQ\",\"invite\",{\"kinds\":[28935]}]" + }, + "compare": false, + "compare_why": "the capture builds the claim event with its own field order; recorded, not compared \u2014 job 008", + "expect": [ + "[\"EVENT\",\"invite\",{\"pubkey\":\"608219ac222037d5914b0bec4441d5a913eafe1f14175ff18ee8fa08a87e085d\",\"kind\":28935,\"created_at\":1700000000,\"content\":\"\",\"tags\":[[\"claim\",\"f540cd7a9033056c23f0f1f53d088202\"],[\"-\"],[\"expiration\",\"1700000600\"]],\"id\":\"d7c06fc53a5ae1239732b3ac2fe81a65e2b0d7711866b4ad12387518e266956a\",\"sig\":\"44ce4a606ea32731cc5404e7f5ad1a158726971159d050d881202d791c5d6368908450df392c0608a3ac20d0bb435ef841dd042efe4322f5d38162444affa687\"}]", + "[\"EOSE\",\"invite\"]" + ] + } + ], + "relay_secret": "683c29961bc5247d177a5019710ee3b776fb07ef636d834b848b4f726ace3f51", + "random": { + "challenge": "7811f2e003e8351d186ff62b7e6ef2dd0ce9e13db27016b6d1fca24a59146fb9", + "invite_code": "f540cd7a9033056c23f0f1f53d088202" + } + }, + { + "id": "live-dispatch", + "challenge": "VTee8ZfgHBR8UaWjWV0Pza7d9-JtjI3g8vJ9O-PKlSk", + "clock": { + "start_ms": 1700000000000 + }, + "seed": 20261023, + "settings": { + "info": { + "relay_url": "wss://nostream.your-domain.com", + "name": "nostream.your-domain.com", + "description": "A nostr relay written in Typescript.", + "banner": "https://nostream.your-domain.com/banner.png", + "icon": "https://nostream.your-domain.com/icon.png", + "pubkey": "replace-with-your-pubkey-in-hex", + "contact": "mailto:operator@your-domain.com", + "terms_of_service": "https://nostream.your-domain.com/terms" + }, + "payments": { + "enabled": false, + "processor": "zebedee", + "feeSchedules": { + "admission": [ + { + "enabled": false, + "description": "Admission fee charged per public key in msats (1000 msats = 1 satoshi)", + "amount": 1000000, + "whitelists": { + "pubkeys": [ + "replace-with-your-pubkey-in-hex" + ], + "event_kinds": [ + 9735 + ] + } + } + ] + }, + "invoiceExpirySeconds": 86400 + }, + "paymentsProcessors": { + "zebedee": { + "baseURL": "https://api.zebedee.io/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/zebedee", + "ipWhitelist": [ + "3.225.112.64", + "::ffff:3.225.112.64" + ] + }, + "lnbits": { + "baseURL": "https://lnbits.your-domain.com/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/lnbits" + }, + "lnurl": { + "invoiceURL": "https://getalby.com/lnurlp/your-username" + }, + "nodeless": { + "baseURL": "https://nodeless.io", + "storeId": "your-nodeless-io-store-id" + }, + "opennode": { + "baseURL": "api.opennode.com", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/opennode" + }, + "nwc": { + "invoiceExpirySeconds": 900, + "replyTimeoutMs": 10000 + } + }, + "nip05": { + "mode": "disabled", + "verifyExpiration": 604800000, + "verifyUpdateFrequency": 86400000, + "maxConsecutiveFailures": 20, + "domainWhitelist": [], + "domainBlacklist": [] + }, + "nip42": { + "authRequired": false, + "sessionExpirySeconds": 0, + "restrictedReads": { + "enabled": false, + "kinds": [ + 4, + 1059 + ] + } + }, + "nip43": { + "enabled": false, + "inviteCodeExpirySeconds": 600, + "defaultMaxUses": 1, + "allowInviteRequests": false, + "inviteRequestWhitelist": [] + }, + "nip45": { + "enabled": true + }, + "nip50": { + "enabled": false, + "language": "simple", + "maxQueryLength": 256 + }, + "nip66": { + "enabled": false, + "probeIntervalSeconds": 3600, + "timeouts": { + "dnsMs": 10000, + "tlsMs": 10000, + "wsRttMs": 10000, + "nip11Ms": 10000 + }, + "targets": [], + "dnsCacheTtlSeconds": 300 + }, + "wot": { + "enabled": false, + "seedPubkey": "", + "minimumFollowers": 1, + "maxDepth": 2, + "refreshIntervalHours": 24 + }, + "nip56": { + "enabled": false, + "trustedModerators": [], + "hideActionableReports": false + }, + "network": { + "maxPayloadSize": 524288, + "trustedProxies": [ + "127.0.0.1", + "::ffff:127.0.0.1", + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "workers": { + "count": 0 + }, + "eventStore": { + "backend": "postgres" + }, + "mirroring": { + "static": [] + }, + "dvm": { + "workers": [] + }, + "limits": { + "rateLimiter": { + "strategy": "ewma" + }, + "invoice": { + "rateLimits": [ + { + "period": 60000, + "rate": 12 + }, + { + "period": 3600000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "invite": { + "rateLimits": [ + { + "description": "5 invite requests per hour per pubkey", + "period": 3600000, + "rate": 5 + } + ] + }, + "admissionCheck": { + "rateLimits": [ + { + "description": "30 admission checks/min or 1 check every 2 seconds", + "period": 60000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "admin": { + "rateLimits": [ + { + "description": "30 admin requests/min", + "period": 60000, + "rate": 30 + } + ], + "loginRateLimits": [ + { + "description": "10 login attempts per 15 minutes", + "period": 900000, + "rate": 10 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "connection": { + "rateLimits": [ + { + "period": 1000, + "rate": 12 + }, + { + "period": 60000, + "rate": 48 + } + ], + "ipWhitelist": [ + "127.0.0.1", + "::1", + "::ffff:127.0.0.1" + ] + }, + "event": { + "retention": { + "maxDays": -1, + "kind": { + "whitelist": [ + 62 + ] + }, + "pubkey": { + "whitelist": [] + } + }, + "eventId": { + "minLeadingZeroBits": 0 + }, + "pow": { + "enabled": false, + "floorBits": 0, + "ceilingBits": 24, + "targetEventsPerSecond": 50, + "periodMs": 60000, + "wotThresholds": [] + }, + "kind": { + "whitelist": [], + "blacklist": [] + }, + "pubkey": { + "minBalance": 0, + "minLeadingZeroBits": 0, + "whitelist": [], + "blacklist": [] + }, + "createdAt": { + "maxPositiveDelta": 900, + "maxNegativeDelta": 0 + }, + "content": [ + { + "description": "64 KB for event kind ranges 0-10 and 40-49", + "maxLength": 102400, + "kinds": [ + [ + 0, + 10 + ], + [ + 40, + 49 + ] + ] + }, + { + "description": "96 KB for event kind ranges 11-39 and 50-max", + "maxLength": 102400, + "kinds": [ + [ + 11, + 39 + ], + [ + 50, + 9007199254740991 + ] + ] + } + ], + "rateLimits": [ + { + "description": "6 events/min for event kinds 0, 3, 40 and 41", + "kinds": [ + 0, + 3, + 40, + 41 + ], + "period": 60000, + "rate": 6 + }, + { + "description": "12 events/min for event kinds 1, 2, 4 and 42", + "kinds": [ + 1, + 2, + 4, + 42 + ], + "period": 60000, + "rate": 12 + }, + { + "description": "30 events/min for event kind ranges 5-7 and 43-49", + "kinds": [ + [ + 5, + 7 + ], + [ + 43, + 49 + ] + ], + "period": 60000, + "rate": 30 + }, + { + "description": "24 events/min for replaceable events and parameterized replaceable events", + "kinds": [ + [ + 10000, + 19999 + ], + [ + 30000, + 39999 + ] + ], + "period": 60000, + "rate": 24 + }, + { + "description": "60 events/min for Marmot group events (kind 445)", + "kinds": [ + 445 + ], + "period": 60000, + "rate": 60 + }, + { + "description": "60 events/min for ephemeral events", + "kinds": [ + [ + 20000, + 29999 + ] + ], + "period": 60000, + "rate": 60 + }, + { + "description": "720 events/hour for all events", + "period": 3600000, + "rate": 720 + } + ], + "whitelists": { + "pubkeys": [], + "ipAddresses": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "client": { + "subscription": { + "maxSubscriptions": 10, + "maxFilters": 10, + "maxFilterValues": 2500, + "maxSubscriptionIdLength": 256, + "maxLimit": 5000, + "minPrefixLength": 4 + } + }, + "message": { + "rateLimits": [ + { + "description": "240 raw messages/min", + "period": 60000, + "rate": 240 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "admin": { + "enabled": false, + "sessionTtlSeconds": 86400, + "nip98": { + "enabled": false, + "allowedPubkeys": [], + "maxSkewSeconds": 60 + }, + "notifications": { + "enabled": false, + "targets": [], + "events": { + "admission.invoice.created": true, + "admission.invoice.paid": true, + "admission.invoice.failed": true, + "settings.changed": true, + "relay.restarted": false + }, + "retry": { + "maxAttempts": 5, + "baseDelayMs": 1000 + }, + "deliveryLogRetentionDays": 30 + } + } + }, + "steps": [ + { + "connect": true, + "expect": [ + "[\"AUTH\",\"{{challenge}}\"]" + ] + }, + { + "send": { + "text": "[\"REQ\",\"a\",{}]" + }, + "expect": [ + "[\"EOSE\",\"a\"]" + ] + }, + { + "send": { + "text": "[\"REQ\",\"b\",{\"kinds\":[1]}]" + }, + "expect": [ + "[\"EOSE\",\"b\"]" + ] + }, + { + "send": { + "text": "[\"EVENT\",{\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"kind\":1,\"created_at\":1700000000,\"content\":\"live\",\"tags\":[],\"id\":\"41989f5fde4fff3831ba1075cb3c00566aa89930bc58feedd298ef8eb344ce73\",\"sig\":\"ddc1e77528d827b22e322a5a3cb9159924dc1222a02e90cf4369fb4d128c5c67d1d0b73ad702589b2c35902ed32b179e2fccdfbe9b100c0c054d37e6c9b94fdb\"}]" + }, + "expect": [ + "[\"OK\",\"41989f5fde4fff3831ba1075cb3c00566aa89930bc58feedd298ef8eb344ce73\",true,\"\"]", + "[\"EVENT\",\"a\",{\"id\":\"41989f5fde4fff3831ba1075cb3c00566aa89930bc58feedd298ef8eb344ce73\",\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"created_at\":1700000000,\"kind\":1,\"tags\":[],\"content\":\"live\",\"sig\":\"ddc1e77528d827b22e322a5a3cb9159924dc1222a02e90cf4369fb4d128c5c67d1d0b73ad702589b2c35902ed32b179e2fccdfbe9b100c0c054d37e6c9b94fdb\"}]", + "[\"EVENT\",\"b\",{\"id\":\"41989f5fde4fff3831ba1075cb3c00566aa89930bc58feedd298ef8eb344ce73\",\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"created_at\":1700000000,\"kind\":1,\"tags\":[],\"content\":\"live\",\"sig\":\"ddc1e77528d827b22e322a5a3cb9159924dc1222a02e90cf4369fb4d128c5c67d1d0b73ad702589b2c35902ed32b179e2fccdfbe9b100c0c054d37e6c9b94fdb\"}]" + ] + } + ], + "relay_secret": "683c29961bc5247d177a5019710ee3b776fb07ef636d834b848b4f726ace3f51", + "random": { + "challenge": "55379ef197e01c147c51a5a3595d0fcdaeddf7e26d8c8de0f2f27d3be3ca9529", + "invite_code": null + } + }, + { + "id": "hidden-report", + "challenge": "mkeHr9a76Y-bugPvb83ZKlt2JqJjz-1zsPQx2KDBspM", + "clock": { + "start_ms": 1700000000000 + }, + "seed": 20261023, + "settings": { + "info": { + "relay_url": "wss://nostream.your-domain.com", + "name": "nostream.your-domain.com", + "description": "A nostr relay written in Typescript.", + "banner": "https://nostream.your-domain.com/banner.png", + "icon": "https://nostream.your-domain.com/icon.png", + "pubkey": "replace-with-your-pubkey-in-hex", + "contact": "mailto:operator@your-domain.com", + "terms_of_service": "https://nostream.your-domain.com/terms" + }, + "payments": { + "enabled": false, + "processor": "zebedee", + "feeSchedules": { + "admission": [ + { + "enabled": false, + "description": "Admission fee charged per public key in msats (1000 msats = 1 satoshi)", + "amount": 1000000, + "whitelists": { + "pubkeys": [ + "replace-with-your-pubkey-in-hex" + ], + "event_kinds": [ + 9735 + ] + } + } + ] + }, + "invoiceExpirySeconds": 86400 + }, + "paymentsProcessors": { + "zebedee": { + "baseURL": "https://api.zebedee.io/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/zebedee", + "ipWhitelist": [ + "3.225.112.64", + "::ffff:3.225.112.64" + ] + }, + "lnbits": { + "baseURL": "https://lnbits.your-domain.com/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/lnbits" + }, + "lnurl": { + "invoiceURL": "https://getalby.com/lnurlp/your-username" + }, + "nodeless": { + "baseURL": "https://nodeless.io", + "storeId": "your-nodeless-io-store-id" + }, + "opennode": { + "baseURL": "api.opennode.com", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/opennode" + }, + "nwc": { + "invoiceExpirySeconds": 900, + "replyTimeoutMs": 10000 + } + }, + "nip05": { + "mode": "disabled", + "verifyExpiration": 604800000, + "verifyUpdateFrequency": 86400000, + "maxConsecutiveFailures": 20, + "domainWhitelist": [], + "domainBlacklist": [] + }, + "nip42": { + "authRequired": false, + "sessionExpirySeconds": 0, + "restrictedReads": { + "enabled": false, + "kinds": [ + 4, + 1059 + ] + } + }, + "nip43": { + "enabled": false, + "inviteCodeExpirySeconds": 600, + "defaultMaxUses": 1, + "allowInviteRequests": false, + "inviteRequestWhitelist": [] + }, + "nip45": { + "enabled": true + }, + "nip50": { + "enabled": false, + "language": "simple", + "maxQueryLength": 256 + }, + "nip66": { + "enabled": false, + "probeIntervalSeconds": 3600, + "timeouts": { + "dnsMs": 10000, + "tlsMs": 10000, + "wsRttMs": 10000, + "nip11Ms": 10000 + }, + "targets": [], + "dnsCacheTtlSeconds": 300 + }, + "wot": { + "enabled": false, + "seedPubkey": "", + "minimumFollowers": 1, + "maxDepth": 2, + "refreshIntervalHours": 24 + }, + "nip56": { + "enabled": true, + "trustedModerators": [], + "hideActionableReports": true + }, + "network": { + "maxPayloadSize": 524288, + "trustedProxies": [ + "127.0.0.1", + "::ffff:127.0.0.1", + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "workers": { + "count": 0 + }, + "eventStore": { + "backend": "postgres" + }, + "mirroring": { + "static": [] + }, + "dvm": { + "workers": [] + }, + "limits": { + "rateLimiter": { + "strategy": "ewma" + }, + "invoice": { + "rateLimits": [ + { + "period": 60000, + "rate": 12 + }, + { + "period": 3600000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "invite": { + "rateLimits": [ + { + "description": "5 invite requests per hour per pubkey", + "period": 3600000, + "rate": 5 + } + ] + }, + "admissionCheck": { + "rateLimits": [ + { + "description": "30 admission checks/min or 1 check every 2 seconds", + "period": 60000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "admin": { + "rateLimits": [ + { + "description": "30 admin requests/min", + "period": 60000, + "rate": 30 + } + ], + "loginRateLimits": [ + { + "description": "10 login attempts per 15 minutes", + "period": 900000, + "rate": 10 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "connection": { + "rateLimits": [ + { + "period": 1000, + "rate": 12 + }, + { + "period": 60000, + "rate": 48 + } + ], + "ipWhitelist": [ + "127.0.0.1", + "::1", + "::ffff:127.0.0.1" + ] + }, + "event": { + "retention": { + "maxDays": -1, + "kind": { + "whitelist": [ + 62 + ] + }, + "pubkey": { + "whitelist": [] + } + }, + "eventId": { + "minLeadingZeroBits": 0 + }, + "pow": { + "enabled": false, + "floorBits": 0, + "ceilingBits": 24, + "targetEventsPerSecond": 50, + "periodMs": 60000, + "wotThresholds": [] + }, + "kind": { + "whitelist": [], + "blacklist": [] + }, + "pubkey": { + "minBalance": 0, + "minLeadingZeroBits": 0, + "whitelist": [], + "blacklist": [] + }, + "createdAt": { + "maxPositiveDelta": 900, + "maxNegativeDelta": 0 + }, + "content": [ + { + "description": "64 KB for event kind ranges 0-10 and 40-49", + "maxLength": 102400, + "kinds": [ + [ + 0, + 10 + ], + [ + 40, + 49 + ] + ] + }, + { + "description": "96 KB for event kind ranges 11-39 and 50-max", + "maxLength": 102400, + "kinds": [ + [ + 11, + 39 + ], + [ + 50, + 9007199254740991 + ] + ] + } + ], + "rateLimits": [ + { + "description": "6 events/min for event kinds 0, 3, 40 and 41", + "kinds": [ + 0, + 3, + 40, + 41 + ], + "period": 60000, + "rate": 6 + }, + { + "description": "12 events/min for event kinds 1, 2, 4 and 42", + "kinds": [ + 1, + 2, + 4, + 42 + ], + "period": 60000, + "rate": 12 + }, + { + "description": "30 events/min for event kind ranges 5-7 and 43-49", + "kinds": [ + [ + 5, + 7 + ], + [ + 43, + 49 + ] + ], + "period": 60000, + "rate": 30 + }, + { + "description": "24 events/min for replaceable events and parameterized replaceable events", + "kinds": [ + [ + 10000, + 19999 + ], + [ + 30000, + 39999 + ] + ], + "period": 60000, + "rate": 24 + }, + { + "description": "60 events/min for Marmot group events (kind 445)", + "kinds": [ + 445 + ], + "period": 60000, + "rate": 60 + }, + { + "description": "60 events/min for ephemeral events", + "kinds": [ + [ + 20000, + 29999 + ] + ], + "period": 60000, + "rate": 60 + }, + { + "description": "720 events/hour for all events", + "period": 3600000, + "rate": 720 + } + ], + "whitelists": { + "pubkeys": [], + "ipAddresses": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "client": { + "subscription": { + "maxSubscriptions": 10, + "maxFilters": 10, + "maxFilterValues": 2500, + "maxSubscriptionIdLength": 256, + "maxLimit": 5000, + "minPrefixLength": 4 + } + }, + "message": { + "rateLimits": [ + { + "description": "240 raw messages/min", + "period": 60000, + "rate": 240 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "admin": { + "enabled": false, + "sessionTtlSeconds": 86400, + "nip98": { + "enabled": false, + "allowedPubkeys": [], + "maxSkewSeconds": 60 + }, + "notifications": { + "enabled": false, + "targets": [], + "events": { + "admission.invoice.created": true, + "admission.invoice.paid": true, + "admission.invoice.failed": true, + "settings.changed": true, + "relay.restarted": false + }, + "retry": { + "maxAttempts": 5, + "baseDelayMs": 1000 + }, + "deliveryLogRetentionDays": 30 + } + } + }, + "steps": [ + { + "connect": true, + "expect": [ + "[\"AUTH\",\"{{challenge}}\"]" + ] + }, + { + "send": { + "text": "[\"REQ\",\"h1\",{}]" + }, + "expect": [ + "[\"EOSE\",\"h1\"]" + ] + }, + { + "send": { + "text": "[\"EVENT\",{\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"kind\":1,\"created_at\":1700000000,\"content\":\"hidden\",\"tags\":[],\"id\":\"0a35d4af0ffe7436ac53de142084f6d2afb7ee655550da78dc90deacc97f0ef7\",\"sig\":\"be0478a0a19342c4f529b0546ff2c5cf1bf0cce20a13d2f0c6d187d33f35c1573fd97c565ff364c2be27c7e8e435c1e45d8457838d320998b3d921f5b6ee4481\"}]" + }, + "expect": [ + "[\"OK\",\"0a35d4af0ffe7436ac53de142084f6d2afb7ee655550da78dc90deacc97f0ef7\",true,\"\"]" + ] + }, + { + "send": { + "text": "[\"EVENT\",{\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"kind\":1,\"created_at\":1700000000,\"content\":\"visible\",\"tags\":[],\"id\":\"83dad21ce447fcdb09a36ab3173d076ae6384e391f99fc9637854a103b54613e\",\"sig\":\"ffeecee01c0c9667288c3d746e6356f768b3984e865d8790476e21f7104fee215af87905dd1efe3b1c592cf8386ad75292a324f4a3760c5e06109ea7e877c9be\"}]" + }, + "expect": [ + "[\"OK\",\"83dad21ce447fcdb09a36ab3173d076ae6384e391f99fc9637854a103b54613e\",true,\"\"]", + "[\"EVENT\",\"h1\",{\"id\":\"83dad21ce447fcdb09a36ab3173d076ae6384e391f99fc9637854a103b54613e\",\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"created_at\":1700000000,\"kind\":1,\"tags\":[],\"content\":\"visible\",\"sig\":\"ffeecee01c0c9667288c3d746e6356f768b3984e865d8790476e21f7104fee215af87905dd1efe3b1c592cf8386ad75292a324f4a3760c5e06109ea7e877c9be\"}]" + ] + } + ], + "relay_secret": "683c29961bc5247d177a5019710ee3b776fb07ef636d834b848b4f726ace3f51", + "actionable": [ + { + "reportedPubkey": null, + "reportedEventId": "0a35d4af0ffe7436ac53de142084f6d2afb7ee655550da78dc90deacc97f0ef7" + } + ], + "random": { + "challenge": "9a4787afd6bbe98f9bba03ef6fcdd92a5b7626a263cfed73b0f431d8a0c1b293", + "invite_code": null + } + }, + { + "id": "drain", + "challenge": "PZLaSI8XIYH2yCRxT08e4WVu_P1u4UTRm0xC-te6DU8", + "clock": { + "start_ms": 1700000000000 + }, + "seed": 20261023, + "settings": { + "info": { + "relay_url": "wss://nostream.your-domain.com", + "name": "nostream.your-domain.com", + "description": "A nostr relay written in Typescript.", + "banner": "https://nostream.your-domain.com/banner.png", + "icon": "https://nostream.your-domain.com/icon.png", + "pubkey": "replace-with-your-pubkey-in-hex", + "contact": "mailto:operator@your-domain.com", + "terms_of_service": "https://nostream.your-domain.com/terms" + }, + "payments": { + "enabled": false, + "processor": "zebedee", + "feeSchedules": { + "admission": [ + { + "enabled": false, + "description": "Admission fee charged per public key in msats (1000 msats = 1 satoshi)", + "amount": 1000000, + "whitelists": { + "pubkeys": [ + "replace-with-your-pubkey-in-hex" + ], + "event_kinds": [ + 9735 + ] + } + } + ] + }, + "invoiceExpirySeconds": 86400 + }, + "paymentsProcessors": { + "zebedee": { + "baseURL": "https://api.zebedee.io/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/zebedee", + "ipWhitelist": [ + "3.225.112.64", + "::ffff:3.225.112.64" + ] + }, + "lnbits": { + "baseURL": "https://lnbits.your-domain.com/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/lnbits" + }, + "lnurl": { + "invoiceURL": "https://getalby.com/lnurlp/your-username" + }, + "nodeless": { + "baseURL": "https://nodeless.io", + "storeId": "your-nodeless-io-store-id" + }, + "opennode": { + "baseURL": "api.opennode.com", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/opennode" + }, + "nwc": { + "invoiceExpirySeconds": 900, + "replyTimeoutMs": 10000 + } + }, + "nip05": { + "mode": "disabled", + "verifyExpiration": 604800000, + "verifyUpdateFrequency": 86400000, + "maxConsecutiveFailures": 20, + "domainWhitelist": [], + "domainBlacklist": [] + }, + "nip42": { + "authRequired": false, + "sessionExpirySeconds": 0, + "restrictedReads": { + "enabled": false, + "kinds": [ + 4, + 1059 + ] + } + }, + "nip43": { + "enabled": false, + "inviteCodeExpirySeconds": 600, + "defaultMaxUses": 1, + "allowInviteRequests": false, + "inviteRequestWhitelist": [] + }, + "nip45": { + "enabled": true + }, + "nip50": { + "enabled": false, + "language": "simple", + "maxQueryLength": 256 + }, + "nip66": { + "enabled": false, + "probeIntervalSeconds": 3600, + "timeouts": { + "dnsMs": 10000, + "tlsMs": 10000, + "wsRttMs": 10000, + "nip11Ms": 10000 + }, + "targets": [], + "dnsCacheTtlSeconds": 300 + }, + "wot": { + "enabled": false, + "seedPubkey": "", + "minimumFollowers": 1, + "maxDepth": 2, + "refreshIntervalHours": 24 + }, + "nip56": { + "enabled": false, + "trustedModerators": [], + "hideActionableReports": false + }, + "network": { + "maxPayloadSize": 524288, + "trustedProxies": [ + "127.0.0.1", + "::ffff:127.0.0.1", + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "workers": { + "count": 0 + }, + "eventStore": { + "backend": "postgres" + }, + "mirroring": { + "static": [] + }, + "dvm": { + "workers": [] + }, + "limits": { + "rateLimiter": { + "strategy": "ewma" + }, + "invoice": { + "rateLimits": [ + { + "period": 60000, + "rate": 12 + }, + { + "period": 3600000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "invite": { + "rateLimits": [ + { + "description": "5 invite requests per hour per pubkey", + "period": 3600000, + "rate": 5 + } + ] + }, + "admissionCheck": { + "rateLimits": [ + { + "description": "30 admission checks/min or 1 check every 2 seconds", + "period": 60000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "admin": { + "rateLimits": [ + { + "description": "30 admin requests/min", + "period": 60000, + "rate": 30 + } + ], + "loginRateLimits": [ + { + "description": "10 login attempts per 15 minutes", + "period": 900000, + "rate": 10 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "connection": { + "rateLimits": [ + { + "period": 1000, + "rate": 12 + }, + { + "period": 60000, + "rate": 48 + } + ], + "ipWhitelist": [ + "127.0.0.1", + "::1", + "::ffff:127.0.0.1" + ] + }, + "event": { + "retention": { + "maxDays": -1, + "kind": { + "whitelist": [ + 62 + ] + }, + "pubkey": { + "whitelist": [] + } + }, + "eventId": { + "minLeadingZeroBits": 0 + }, + "pow": { + "enabled": false, + "floorBits": 0, + "ceilingBits": 24, + "targetEventsPerSecond": 50, + "periodMs": 60000, + "wotThresholds": [] + }, + "kind": { + "whitelist": [], + "blacklist": [] + }, + "pubkey": { + "minBalance": 0, + "minLeadingZeroBits": 0, + "whitelist": [], + "blacklist": [] + }, + "createdAt": { + "maxPositiveDelta": 900, + "maxNegativeDelta": 0 + }, + "content": [ + { + "description": "64 KB for event kind ranges 0-10 and 40-49", + "maxLength": 102400, + "kinds": [ + [ + 0, + 10 + ], + [ + 40, + 49 + ] + ] + }, + { + "description": "96 KB for event kind ranges 11-39 and 50-max", + "maxLength": 102400, + "kinds": [ + [ + 11, + 39 + ], + [ + 50, + 9007199254740991 + ] + ] + } + ], + "rateLimits": [ + { + "description": "6 events/min for event kinds 0, 3, 40 and 41", + "kinds": [ + 0, + 3, + 40, + 41 + ], + "period": 60000, + "rate": 6 + }, + { + "description": "12 events/min for event kinds 1, 2, 4 and 42", + "kinds": [ + 1, + 2, + 4, + 42 + ], + "period": 60000, + "rate": 12 + }, + { + "description": "30 events/min for event kind ranges 5-7 and 43-49", + "kinds": [ + [ + 5, + 7 + ], + [ + 43, + 49 + ] + ], + "period": 60000, + "rate": 30 + }, + { + "description": "24 events/min for replaceable events and parameterized replaceable events", + "kinds": [ + [ + 10000, + 19999 + ], + [ + 30000, + 39999 + ] + ], + "period": 60000, + "rate": 24 + }, + { + "description": "60 events/min for Marmot group events (kind 445)", + "kinds": [ + 445 + ], + "period": 60000, + "rate": 60 + }, + { + "description": "60 events/min for ephemeral events", + "kinds": [ + [ + 20000, + 29999 + ] + ], + "period": 60000, + "rate": 60 + }, + { + "description": "720 events/hour for all events", + "period": 3600000, + "rate": 720 + } + ], + "whitelists": { + "pubkeys": [], + "ipAddresses": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "client": { + "subscription": { + "maxSubscriptions": 10, + "maxFilters": 10, + "maxFilterValues": 2500, + "maxSubscriptionIdLength": 256, + "maxLimit": 5000, + "minPrefixLength": 4 + } + }, + "message": { + "rateLimits": [ + { + "description": "240 raw messages/min", + "period": 60000, + "rate": 240 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "admin": { + "enabled": false, + "sessionTtlSeconds": 86400, + "nip98": { + "enabled": false, + "allowedPubkeys": [], + "maxSkewSeconds": 60 + }, + "notifications": { + "enabled": false, + "targets": [], + "events": { + "admission.invoice.created": true, + "admission.invoice.paid": true, + "admission.invoice.failed": true, + "settings.changed": true, + "relay.restarted": false + }, + "retry": { + "maxAttempts": 5, + "baseDelayMs": 1000 + }, + "deliveryLogRetentionDays": 30 + } + } + }, + "steps": [ + { + "connect": true, + "expect": [ + "[\"AUTH\",\"{{challenge}}\"]" + ] + }, + { + "send": { + "text": "[\"REQ\",\"d1\",{\"kinds\":[1]}]" + }, + "expect": [ + "[\"EOSE\",\"d1\"]" + ] + }, + { + "send": { + "text": "[\"REQ\",\"d2\",{\"kinds\":[4]}]" + }, + "expect": [ + "[\"EOSE\",\"d2\"]" + ] + }, + { + "drain": true, + "expect": [ + "[\"CLOSED\",\"d1\",\"error: relay shutting down\"]", + "[\"CLOSED\",\"d2\",\"error: relay shutting down\"]" + ] + } + ], + "relay_secret": "683c29961bc5247d177a5019710ee3b776fb07ef636d834b848b4f726ace3f51", + "random": { + "challenge": "3d92da488f172181f6c824714f4f1ee1656efcfd6ee144d19b4c42fad7ba0d4f", + "invite_code": null + } + }, + { + "id": "liveness", + "challenge": "aunq-blTqaGkPEnfrgeMPRzAWdXBAbW-6lAI1Zh12aA", + "clock": { + "start_ms": 1700000000000 + }, + "seed": 20261023, + "settings": { + "info": { + "relay_url": "wss://nostream.your-domain.com", + "name": "nostream.your-domain.com", + "description": "A nostr relay written in Typescript.", + "banner": "https://nostream.your-domain.com/banner.png", + "icon": "https://nostream.your-domain.com/icon.png", + "pubkey": "replace-with-your-pubkey-in-hex", + "contact": "mailto:operator@your-domain.com", + "terms_of_service": "https://nostream.your-domain.com/terms" + }, + "payments": { + "enabled": false, + "processor": "zebedee", + "feeSchedules": { + "admission": [ + { + "enabled": false, + "description": "Admission fee charged per public key in msats (1000 msats = 1 satoshi)", + "amount": 1000000, + "whitelists": { + "pubkeys": [ + "replace-with-your-pubkey-in-hex" + ], + "event_kinds": [ + 9735 + ] + } + } + ] + }, + "invoiceExpirySeconds": 86400 + }, + "paymentsProcessors": { + "zebedee": { + "baseURL": "https://api.zebedee.io/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/zebedee", + "ipWhitelist": [ + "3.225.112.64", + "::ffff:3.225.112.64" + ] + }, + "lnbits": { + "baseURL": "https://lnbits.your-domain.com/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/lnbits" + }, + "lnurl": { + "invoiceURL": "https://getalby.com/lnurlp/your-username" + }, + "nodeless": { + "baseURL": "https://nodeless.io", + "storeId": "your-nodeless-io-store-id" + }, + "opennode": { + "baseURL": "api.opennode.com", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/opennode" + }, + "nwc": { + "invoiceExpirySeconds": 900, + "replyTimeoutMs": 10000 + } + }, + "nip05": { + "mode": "disabled", + "verifyExpiration": 604800000, + "verifyUpdateFrequency": 86400000, + "maxConsecutiveFailures": 20, + "domainWhitelist": [], + "domainBlacklist": [] + }, + "nip42": { + "authRequired": false, + "sessionExpirySeconds": 0, + "restrictedReads": { + "enabled": false, + "kinds": [ + 4, + 1059 + ] + } + }, + "nip43": { + "enabled": false, + "inviteCodeExpirySeconds": 600, + "defaultMaxUses": 1, + "allowInviteRequests": false, + "inviteRequestWhitelist": [] + }, + "nip45": { + "enabled": true + }, + "nip50": { + "enabled": false, + "language": "simple", + "maxQueryLength": 256 + }, + "nip66": { + "enabled": false, + "probeIntervalSeconds": 3600, + "timeouts": { + "dnsMs": 10000, + "tlsMs": 10000, + "wsRttMs": 10000, + "nip11Ms": 10000 + }, + "targets": [], + "dnsCacheTtlSeconds": 300 + }, + "wot": { + "enabled": false, + "seedPubkey": "", + "minimumFollowers": 1, + "maxDepth": 2, + "refreshIntervalHours": 24 + }, + "nip56": { + "enabled": false, + "trustedModerators": [], + "hideActionableReports": false + }, + "network": { + "maxPayloadSize": 524288, + "trustedProxies": [ + "127.0.0.1", + "::ffff:127.0.0.1", + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "workers": { + "count": 0 + }, + "eventStore": { + "backend": "postgres" + }, + "mirroring": { + "static": [] + }, + "dvm": { + "workers": [] + }, + "limits": { + "rateLimiter": { + "strategy": "ewma" + }, + "invoice": { + "rateLimits": [ + { + "period": 60000, + "rate": 12 + }, + { + "period": 3600000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "invite": { + "rateLimits": [ + { + "description": "5 invite requests per hour per pubkey", + "period": 3600000, + "rate": 5 + } + ] + }, + "admissionCheck": { + "rateLimits": [ + { + "description": "30 admission checks/min or 1 check every 2 seconds", + "period": 60000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "admin": { + "rateLimits": [ + { + "description": "30 admin requests/min", + "period": 60000, + "rate": 30 + } + ], + "loginRateLimits": [ + { + "description": "10 login attempts per 15 minutes", + "period": 900000, + "rate": 10 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "connection": { + "rateLimits": [ + { + "period": 1000, + "rate": 12 + }, + { + "period": 60000, + "rate": 48 + } + ], + "ipWhitelist": [ + "127.0.0.1", + "::1", + "::ffff:127.0.0.1" + ] + }, + "event": { + "retention": { + "maxDays": -1, + "kind": { + "whitelist": [ + 62 + ] + }, + "pubkey": { + "whitelist": [] + } + }, + "eventId": { + "minLeadingZeroBits": 0 + }, + "pow": { + "enabled": false, + "floorBits": 0, + "ceilingBits": 24, + "targetEventsPerSecond": 50, + "periodMs": 60000, + "wotThresholds": [] + }, + "kind": { + "whitelist": [], + "blacklist": [] + }, + "pubkey": { + "minBalance": 0, + "minLeadingZeroBits": 0, + "whitelist": [], + "blacklist": [] + }, + "createdAt": { + "maxPositiveDelta": 900, + "maxNegativeDelta": 0 + }, + "content": [ + { + "description": "64 KB for event kind ranges 0-10 and 40-49", + "maxLength": 102400, + "kinds": [ + [ + 0, + 10 + ], + [ + 40, + 49 + ] + ] + }, + { + "description": "96 KB for event kind ranges 11-39 and 50-max", + "maxLength": 102400, + "kinds": [ + [ + 11, + 39 + ], + [ + 50, + 9007199254740991 + ] + ] + } + ], + "rateLimits": [ + { + "description": "6 events/min for event kinds 0, 3, 40 and 41", + "kinds": [ + 0, + 3, + 40, + 41 + ], + "period": 60000, + "rate": 6 + }, + { + "description": "12 events/min for event kinds 1, 2, 4 and 42", + "kinds": [ + 1, + 2, + 4, + 42 + ], + "period": 60000, + "rate": 12 + }, + { + "description": "30 events/min for event kind ranges 5-7 and 43-49", + "kinds": [ + [ + 5, + 7 + ], + [ + 43, + 49 + ] + ], + "period": 60000, + "rate": 30 + }, + { + "description": "24 events/min for replaceable events and parameterized replaceable events", + "kinds": [ + [ + 10000, + 19999 + ], + [ + 30000, + 39999 + ] + ], + "period": 60000, + "rate": 24 + }, + { + "description": "60 events/min for Marmot group events (kind 445)", + "kinds": [ + 445 + ], + "period": 60000, + "rate": 60 + }, + { + "description": "60 events/min for ephemeral events", + "kinds": [ + [ + 20000, + 29999 + ] + ], + "period": 60000, + "rate": 60 + }, + { + "description": "720 events/hour for all events", + "period": 3600000, + "rate": 720 + } + ], + "whitelists": { + "pubkeys": [], + "ipAddresses": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "client": { + "subscription": { + "maxSubscriptions": 10, + "maxFilters": 10, + "maxFilterValues": 2500, + "maxSubscriptionIdLength": 256, + "maxLimit": 5000, + "minPrefixLength": 4 + } + }, + "message": { + "rateLimits": [ + { + "description": "240 raw messages/min", + "period": 60000, + "rate": 240 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "admin": { + "enabled": false, + "sessionTtlSeconds": 86400, + "nip98": { + "enabled": false, + "allowedPubkeys": [], + "maxSkewSeconds": 60 + }, + "notifications": { + "enabled": false, + "targets": [], + "events": { + "admission.invoice.created": true, + "admission.invoice.paid": true, + "admission.invoice.failed": true, + "settings.changed": true, + "relay.restarted": false + }, + "retry": { + "maxAttempts": 5, + "baseDelayMs": 1000 + }, + "deliveryLogRetentionDays": 30 + } + } + }, + "steps": [ + { + "connect": true, + "expect": [ + "[\"AUTH\",\"{{challenge}}\"]" + ] + } + ], + "liveness_interval_ms": 20, + "final_close": { + "code": 1005, + "reason": "" + }, + "relay_secret": "683c29961bc5247d177a5019710ee3b776fb07ef636d834b848b4f726ace3f51", + "random": { + "challenge": "6ae9eaf9b953a9a1a43c49dfae078c3d1cc059d5c101b5beea5008d59875d9a0", + "invite_code": null + } + }, + { + "id": "compression-offer", + "challenge": "GJictN-DV8JbkUBs7f8vJZuWzs9pEkQqKHo3TCuOpA0", + "clock": { + "start_ms": 1700000000000 + }, + "seed": 20261023, + "settings": { + "info": { + "relay_url": "wss://nostream.your-domain.com", + "name": "nostream.your-domain.com", + "description": "A nostr relay written in Typescript.", + "banner": "https://nostream.your-domain.com/banner.png", + "icon": "https://nostream.your-domain.com/icon.png", + "pubkey": "replace-with-your-pubkey-in-hex", + "contact": "mailto:operator@your-domain.com", + "terms_of_service": "https://nostream.your-domain.com/terms" + }, + "payments": { + "enabled": false, + "processor": "zebedee", + "feeSchedules": { + "admission": [ + { + "enabled": false, + "description": "Admission fee charged per public key in msats (1000 msats = 1 satoshi)", + "amount": 1000000, + "whitelists": { + "pubkeys": [ + "replace-with-your-pubkey-in-hex" + ], + "event_kinds": [ + 9735 + ] + } + } + ] + }, + "invoiceExpirySeconds": 86400 + }, + "paymentsProcessors": { + "zebedee": { + "baseURL": "https://api.zebedee.io/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/zebedee", + "ipWhitelist": [ + "3.225.112.64", + "::ffff:3.225.112.64" + ] + }, + "lnbits": { + "baseURL": "https://lnbits.your-domain.com/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/lnbits" + }, + "lnurl": { + "invoiceURL": "https://getalby.com/lnurlp/your-username" + }, + "nodeless": { + "baseURL": "https://nodeless.io", + "storeId": "your-nodeless-io-store-id" + }, + "opennode": { + "baseURL": "api.opennode.com", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/opennode" + }, + "nwc": { + "invoiceExpirySeconds": 900, + "replyTimeoutMs": 10000 + } + }, + "nip05": { + "mode": "disabled", + "verifyExpiration": 604800000, + "verifyUpdateFrequency": 86400000, + "maxConsecutiveFailures": 20, + "domainWhitelist": [], + "domainBlacklist": [] + }, + "nip42": { + "authRequired": false, + "sessionExpirySeconds": 0, + "restrictedReads": { + "enabled": false, + "kinds": [ + 4, + 1059 + ] + } + }, + "nip43": { + "enabled": false, + "inviteCodeExpirySeconds": 600, + "defaultMaxUses": 1, + "allowInviteRequests": false, + "inviteRequestWhitelist": [] + }, + "nip45": { + "enabled": true + }, + "nip50": { + "enabled": false, + "language": "simple", + "maxQueryLength": 256 + }, + "nip66": { + "enabled": false, + "probeIntervalSeconds": 3600, + "timeouts": { + "dnsMs": 10000, + "tlsMs": 10000, + "wsRttMs": 10000, + "nip11Ms": 10000 + }, + "targets": [], + "dnsCacheTtlSeconds": 300 + }, + "wot": { + "enabled": false, + "seedPubkey": "", + "minimumFollowers": 1, + "maxDepth": 2, + "refreshIntervalHours": 24 + }, + "nip56": { + "enabled": false, + "trustedModerators": [], + "hideActionableReports": false + }, + "network": { + "maxPayloadSize": 524288, + "trustedProxies": [ + "127.0.0.1", + "::ffff:127.0.0.1", + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "workers": { + "count": 0 + }, + "eventStore": { + "backend": "postgres" + }, + "mirroring": { + "static": [] + }, + "dvm": { + "workers": [] + }, + "limits": { + "rateLimiter": { + "strategy": "ewma" + }, + "invoice": { + "rateLimits": [ + { + "period": 60000, + "rate": 12 + }, + { + "period": 3600000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "invite": { + "rateLimits": [ + { + "description": "5 invite requests per hour per pubkey", + "period": 3600000, + "rate": 5 + } + ] + }, + "admissionCheck": { + "rateLimits": [ + { + "description": "30 admission checks/min or 1 check every 2 seconds", + "period": 60000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "admin": { + "rateLimits": [ + { + "description": "30 admin requests/min", + "period": 60000, + "rate": 30 + } + ], + "loginRateLimits": [ + { + "description": "10 login attempts per 15 minutes", + "period": 900000, + "rate": 10 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "connection": { + "rateLimits": [ + { + "period": 1000, + "rate": 12 + }, + { + "period": 60000, + "rate": 48 + } + ], + "ipWhitelist": [ + "127.0.0.1", + "::1", + "::ffff:127.0.0.1" + ] + }, + "event": { + "retention": { + "maxDays": -1, + "kind": { + "whitelist": [ + 62 + ] + }, + "pubkey": { + "whitelist": [] + } + }, + "eventId": { + "minLeadingZeroBits": 0 + }, + "pow": { + "enabled": false, + "floorBits": 0, + "ceilingBits": 24, + "targetEventsPerSecond": 50, + "periodMs": 60000, + "wotThresholds": [] + }, + "kind": { + "whitelist": [], + "blacklist": [] + }, + "pubkey": { + "minBalance": 0, + "minLeadingZeroBits": 0, + "whitelist": [], + "blacklist": [] + }, + "createdAt": { + "maxPositiveDelta": 900, + "maxNegativeDelta": 0 + }, + "content": [ + { + "description": "64 KB for event kind ranges 0-10 and 40-49", + "maxLength": 102400, + "kinds": [ + [ + 0, + 10 + ], + [ + 40, + 49 + ] + ] + }, + { + "description": "96 KB for event kind ranges 11-39 and 50-max", + "maxLength": 102400, + "kinds": [ + [ + 11, + 39 + ], + [ + 50, + 9007199254740991 + ] + ] + } + ], + "rateLimits": [ + { + "description": "6 events/min for event kinds 0, 3, 40 and 41", + "kinds": [ + 0, + 3, + 40, + 41 + ], + "period": 60000, + "rate": 6 + }, + { + "description": "12 events/min for event kinds 1, 2, 4 and 42", + "kinds": [ + 1, + 2, + 4, + 42 + ], + "period": 60000, + "rate": 12 + }, + { + "description": "30 events/min for event kind ranges 5-7 and 43-49", + "kinds": [ + [ + 5, + 7 + ], + [ + 43, + 49 + ] + ], + "period": 60000, + "rate": 30 + }, + { + "description": "24 events/min for replaceable events and parameterized replaceable events", + "kinds": [ + [ + 10000, + 19999 + ], + [ + 30000, + 39999 + ] + ], + "period": 60000, + "rate": 24 + }, + { + "description": "60 events/min for Marmot group events (kind 445)", + "kinds": [ + 445 + ], + "period": 60000, + "rate": 60 + }, + { + "description": "60 events/min for ephemeral events", + "kinds": [ + [ + 20000, + 29999 + ] + ], + "period": 60000, + "rate": 60 + }, + { + "description": "720 events/hour for all events", + "period": 3600000, + "rate": 720 + } + ], + "whitelists": { + "pubkeys": [], + "ipAddresses": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "client": { + "subscription": { + "maxSubscriptions": 10, + "maxFilters": 10, + "maxFilterValues": 2500, + "maxSubscriptionIdLength": 256, + "maxLimit": 5000, + "minPrefixLength": 4 + } + }, + "message": { + "rateLimits": [ + { + "description": "240 raw messages/min", + "period": 60000, + "rate": 240 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "admin": { + "enabled": false, + "sessionTtlSeconds": 86400, + "nip98": { + "enabled": false, + "allowedPubkeys": [], + "maxSkewSeconds": 60 + }, + "notifications": { + "enabled": false, + "targets": [], + "events": { + "admission.invoice.created": true, + "admission.invoice.paid": true, + "admission.invoice.failed": true, + "settings.changed": true, + "relay.restarted": false + }, + "retry": { + "maxAttempts": 5, + "baseDelayMs": 1000 + }, + "deliveryLogRetentionDays": 30 + } + } + }, + "steps": [ + { + "connect": true, + "expect": [ + "[\"AUTH\",\"{{challenge}}\"]" + ] + }, + { + "send": { + "text": "[\"REQ\",\"z1\",{\"kinds\":[1]}]" + }, + "expect": [ + "[\"EOSE\",\"z1\"]" + ] + } + ], + "handshake": { + "extensions": "permessage-deflate; client_max_window_bits", + "response_extensions": null + }, + "relay_secret": "683c29961bc5247d177a5019710ee3b776fb07ef636d834b848b4f726ace3f51", + "random": { + "challenge": "18989cb4df8357c25b91406cedff2f259b96cecf6912442a287a374c2b8ea40d", + "invite_code": null + } + }, + { + "id": "count-disabled", + "challenge": "hlDKgGiODXapHXvC5A3Qwap3jMg6mRXrOg1S8AIbLoI", + "clock": { + "start_ms": 1700000000000 + }, + "seed": 20261023, + "settings": { + "info": { + "relay_url": "wss://nostream.your-domain.com", + "name": "nostream.your-domain.com", + "description": "A nostr relay written in Typescript.", + "banner": "https://nostream.your-domain.com/banner.png", + "icon": "https://nostream.your-domain.com/icon.png", + "pubkey": "replace-with-your-pubkey-in-hex", + "contact": "mailto:operator@your-domain.com", + "terms_of_service": "https://nostream.your-domain.com/terms" + }, + "payments": { + "enabled": false, + "processor": "zebedee", + "feeSchedules": { + "admission": [ + { + "enabled": false, + "description": "Admission fee charged per public key in msats (1000 msats = 1 satoshi)", + "amount": 1000000, + "whitelists": { + "pubkeys": [ + "replace-with-your-pubkey-in-hex" + ], + "event_kinds": [ + 9735 + ] + } + } + ] + }, + "invoiceExpirySeconds": 86400 + }, + "paymentsProcessors": { + "zebedee": { + "baseURL": "https://api.zebedee.io/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/zebedee", + "ipWhitelist": [ + "3.225.112.64", + "::ffff:3.225.112.64" + ] + }, + "lnbits": { + "baseURL": "https://lnbits.your-domain.com/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/lnbits" + }, + "lnurl": { + "invoiceURL": "https://getalby.com/lnurlp/your-username" + }, + "nodeless": { + "baseURL": "https://nodeless.io", + "storeId": "your-nodeless-io-store-id" + }, + "opennode": { + "baseURL": "api.opennode.com", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/opennode" + }, + "nwc": { + "invoiceExpirySeconds": 900, + "replyTimeoutMs": 10000 + } + }, + "nip05": { + "mode": "disabled", + "verifyExpiration": 604800000, + "verifyUpdateFrequency": 86400000, + "maxConsecutiveFailures": 20, + "domainWhitelist": [], + "domainBlacklist": [] + }, + "nip42": { + "authRequired": false, + "sessionExpirySeconds": 0, + "restrictedReads": { + "enabled": false, + "kinds": [ + 4, + 1059 + ] + } + }, + "nip43": { + "enabled": false, + "inviteCodeExpirySeconds": 600, + "defaultMaxUses": 1, + "allowInviteRequests": false, + "inviteRequestWhitelist": [] + }, + "nip45": { + "enabled": false + }, + "nip50": { + "enabled": false, + "language": "simple", + "maxQueryLength": 256 + }, + "nip66": { + "enabled": false, + "probeIntervalSeconds": 3600, + "timeouts": { + "dnsMs": 10000, + "tlsMs": 10000, + "wsRttMs": 10000, + "nip11Ms": 10000 + }, + "targets": [], + "dnsCacheTtlSeconds": 300 + }, + "wot": { + "enabled": false, + "seedPubkey": "", + "minimumFollowers": 1, + "maxDepth": 2, + "refreshIntervalHours": 24 + }, + "nip56": { + "enabled": false, + "trustedModerators": [], + "hideActionableReports": false + }, + "network": { + "maxPayloadSize": 524288, + "trustedProxies": [ + "127.0.0.1", + "::ffff:127.0.0.1", + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "workers": { + "count": 0 + }, + "eventStore": { + "backend": "postgres" + }, + "mirroring": { + "static": [] + }, + "dvm": { + "workers": [] + }, + "limits": { + "rateLimiter": { + "strategy": "ewma" + }, + "invoice": { + "rateLimits": [ + { + "period": 60000, + "rate": 12 + }, + { + "period": 3600000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "invite": { + "rateLimits": [ + { + "description": "5 invite requests per hour per pubkey", + "period": 3600000, + "rate": 5 + } + ] + }, + "admissionCheck": { + "rateLimits": [ + { + "description": "30 admission checks/min or 1 check every 2 seconds", + "period": 60000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "admin": { + "rateLimits": [ + { + "description": "30 admin requests/min", + "period": 60000, + "rate": 30 + } + ], + "loginRateLimits": [ + { + "description": "10 login attempts per 15 minutes", + "period": 900000, + "rate": 10 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "connection": { + "rateLimits": [ + { + "period": 1000, + "rate": 12 + }, + { + "period": 60000, + "rate": 48 + } + ], + "ipWhitelist": [ + "127.0.0.1", + "::1", + "::ffff:127.0.0.1" + ] + }, + "event": { + "retention": { + "maxDays": -1, + "kind": { + "whitelist": [ + 62 + ] + }, + "pubkey": { + "whitelist": [] + } + }, + "eventId": { + "minLeadingZeroBits": 0 + }, + "pow": { + "enabled": false, + "floorBits": 0, + "ceilingBits": 24, + "targetEventsPerSecond": 50, + "periodMs": 60000, + "wotThresholds": [] + }, + "kind": { + "whitelist": [], + "blacklist": [] + }, + "pubkey": { + "minBalance": 0, + "minLeadingZeroBits": 0, + "whitelist": [], + "blacklist": [] + }, + "createdAt": { + "maxPositiveDelta": 900, + "maxNegativeDelta": 0 + }, + "content": [ + { + "description": "64 KB for event kind ranges 0-10 and 40-49", + "maxLength": 102400, + "kinds": [ + [ + 0, + 10 + ], + [ + 40, + 49 + ] + ] + }, + { + "description": "96 KB for event kind ranges 11-39 and 50-max", + "maxLength": 102400, + "kinds": [ + [ + 11, + 39 + ], + [ + 50, + 9007199254740991 + ] + ] + } + ], + "rateLimits": [ + { + "description": "6 events/min for event kinds 0, 3, 40 and 41", + "kinds": [ + 0, + 3, + 40, + 41 + ], + "period": 60000, + "rate": 6 + }, + { + "description": "12 events/min for event kinds 1, 2, 4 and 42", + "kinds": [ + 1, + 2, + 4, + 42 + ], + "period": 60000, + "rate": 12 + }, + { + "description": "30 events/min for event kind ranges 5-7 and 43-49", + "kinds": [ + [ + 5, + 7 + ], + [ + 43, + 49 + ] + ], + "period": 60000, + "rate": 30 + }, + { + "description": "24 events/min for replaceable events and parameterized replaceable events", + "kinds": [ + [ + 10000, + 19999 + ], + [ + 30000, + 39999 + ] + ], + "period": 60000, + "rate": 24 + }, + { + "description": "60 events/min for Marmot group events (kind 445)", + "kinds": [ + 445 + ], + "period": 60000, + "rate": 60 + }, + { + "description": "60 events/min for ephemeral events", + "kinds": [ + [ + 20000, + 29999 + ] + ], + "period": 60000, + "rate": 60 + }, + { + "description": "720 events/hour for all events", + "period": 3600000, + "rate": 720 + } + ], + "whitelists": { + "pubkeys": [], + "ipAddresses": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "client": { + "subscription": { + "maxSubscriptions": 10, + "maxFilters": 10, + "maxFilterValues": 2500, + "maxSubscriptionIdLength": 256, + "maxLimit": 5000, + "minPrefixLength": 4 + } + }, + "message": { + "rateLimits": [ + { + "description": "240 raw messages/min", + "period": 60000, + "rate": 240 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "admin": { + "enabled": false, + "sessionTtlSeconds": 86400, + "nip98": { + "enabled": false, + "allowedPubkeys": [], + "maxSkewSeconds": 60 + }, + "notifications": { + "enabled": false, + "targets": [], + "events": { + "admission.invoice.created": true, + "admission.invoice.paid": true, + "admission.invoice.failed": true, + "settings.changed": true, + "relay.restarted": false + }, + "retry": { + "maxAttempts": 5, + "baseDelayMs": 1000 + }, + "deliveryLogRetentionDays": 30 + } + } + }, + "steps": [ + { + "connect": true, + "expect": [ + "[\"AUTH\",\"{{challenge}}\"]" + ] + }, + { + "send": { + "text": "[\"COUNT\",\"c1\",{\"kinds\":[1]}]" + }, + "expect": [ + "[\"CLOSED\",\"c1\",\"COUNT is disabled by relay configuration\"]" + ] + } + ], + "relay_secret": "683c29961bc5247d177a5019710ee3b776fb07ef636d834b848b4f726ace3f51", + "random": { + "challenge": "8650ca80688e0d76a91d7bc2e40dd0c1aa778cc83a9915eb3a0d52f0021b2e82", + "invite_code": null + } + }, + { + "id": "nip42-expiry", + "challenge": "9qs-4nN9DcUudxbrojxBp0_mwyW81WayAZ9WYy5AI40", + "clock": { + "start_ms": 1700000000000 + }, + "seed": 20261023, + "settings": { + "info": { + "relay_url": "wss://nostream.your-domain.com", + "name": "nostream.your-domain.com", + "description": "A nostr relay written in Typescript.", + "banner": "https://nostream.your-domain.com/banner.png", + "icon": "https://nostream.your-domain.com/icon.png", + "pubkey": "replace-with-your-pubkey-in-hex", + "contact": "mailto:operator@your-domain.com", + "terms_of_service": "https://nostream.your-domain.com/terms" + }, + "payments": { + "enabled": false, + "processor": "zebedee", + "feeSchedules": { + "admission": [ + { + "enabled": false, + "description": "Admission fee charged per public key in msats (1000 msats = 1 satoshi)", + "amount": 1000000, + "whitelists": { + "pubkeys": [ + "replace-with-your-pubkey-in-hex" + ], + "event_kinds": [ + 9735 + ] + } + } + ] + }, + "invoiceExpirySeconds": 86400 + }, + "paymentsProcessors": { + "zebedee": { + "baseURL": "https://api.zebedee.io/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/zebedee", + "ipWhitelist": [ + "3.225.112.64", + "::ffff:3.225.112.64" + ] + }, + "lnbits": { + "baseURL": "https://lnbits.your-domain.com/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/lnbits" + }, + "lnurl": { + "invoiceURL": "https://getalby.com/lnurlp/your-username" + }, + "nodeless": { + "baseURL": "https://nodeless.io", + "storeId": "your-nodeless-io-store-id" + }, + "opennode": { + "baseURL": "api.opennode.com", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/opennode" + }, + "nwc": { + "invoiceExpirySeconds": 900, + "replyTimeoutMs": 10000 + } + }, + "nip05": { + "mode": "disabled", + "verifyExpiration": 604800000, + "verifyUpdateFrequency": 86400000, + "maxConsecutiveFailures": 20, + "domainWhitelist": [], + "domainBlacklist": [] + }, + "nip42": { + "authRequired": false, + "sessionExpirySeconds": 1, + "restrictedReads": { + "enabled": true, + "kinds": [ + 4 + ] + } + }, + "nip43": { + "enabled": false, + "inviteCodeExpirySeconds": 600, + "defaultMaxUses": 1, + "allowInviteRequests": false, + "inviteRequestWhitelist": [] + }, + "nip45": { + "enabled": true + }, + "nip50": { + "enabled": false, + "language": "simple", + "maxQueryLength": 256 + }, + "nip66": { + "enabled": false, + "probeIntervalSeconds": 3600, + "timeouts": { + "dnsMs": 10000, + "tlsMs": 10000, + "wsRttMs": 10000, + "nip11Ms": 10000 + }, + "targets": [], + "dnsCacheTtlSeconds": 300 + }, + "wot": { + "enabled": false, + "seedPubkey": "", + "minimumFollowers": 1, + "maxDepth": 2, + "refreshIntervalHours": 24 + }, + "nip56": { + "enabled": false, + "trustedModerators": [], + "hideActionableReports": false + }, + "network": { + "maxPayloadSize": 524288, + "trustedProxies": [ + "127.0.0.1", + "::ffff:127.0.0.1", + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "workers": { + "count": 0 + }, + "eventStore": { + "backend": "postgres" + }, + "mirroring": { + "static": [] + }, + "dvm": { + "workers": [] + }, + "limits": { + "rateLimiter": { + "strategy": "ewma" + }, + "invoice": { + "rateLimits": [ + { + "period": 60000, + "rate": 12 + }, + { + "period": 3600000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "invite": { + "rateLimits": [ + { + "description": "5 invite requests per hour per pubkey", + "period": 3600000, + "rate": 5 + } + ] + }, + "admissionCheck": { + "rateLimits": [ + { + "description": "30 admission checks/min or 1 check every 2 seconds", + "period": 60000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "admin": { + "rateLimits": [ + { + "description": "30 admin requests/min", + "period": 60000, + "rate": 30 + } + ], + "loginRateLimits": [ + { + "description": "10 login attempts per 15 minutes", + "period": 900000, + "rate": 10 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "connection": { + "rateLimits": [ + { + "period": 1000, + "rate": 12 + }, + { + "period": 60000, + "rate": 48 + } + ], + "ipWhitelist": [ + "127.0.0.1", + "::1", + "::ffff:127.0.0.1" + ] + }, + "event": { + "retention": { + "maxDays": -1, + "kind": { + "whitelist": [ + 62 + ] + }, + "pubkey": { + "whitelist": [] + } + }, + "eventId": { + "minLeadingZeroBits": 0 + }, + "pow": { + "enabled": false, + "floorBits": 0, + "ceilingBits": 24, + "targetEventsPerSecond": 50, + "periodMs": 60000, + "wotThresholds": [] + }, + "kind": { + "whitelist": [], + "blacklist": [] + }, + "pubkey": { + "minBalance": 0, + "minLeadingZeroBits": 0, + "whitelist": [], + "blacklist": [] + }, + "createdAt": { + "maxPositiveDelta": 900, + "maxNegativeDelta": 0 + }, + "content": [ + { + "description": "64 KB for event kind ranges 0-10 and 40-49", + "maxLength": 102400, + "kinds": [ + [ + 0, + 10 + ], + [ + 40, + 49 + ] + ] + }, + { + "description": "96 KB for event kind ranges 11-39 and 50-max", + "maxLength": 102400, + "kinds": [ + [ + 11, + 39 + ], + [ + 50, + 9007199254740991 + ] + ] + } + ], + "rateLimits": [ + { + "description": "6 events/min for event kinds 0, 3, 40 and 41", + "kinds": [ + 0, + 3, + 40, + 41 + ], + "period": 60000, + "rate": 6 + }, + { + "description": "12 events/min for event kinds 1, 2, 4 and 42", + "kinds": [ + 1, + 2, + 4, + 42 + ], + "period": 60000, + "rate": 12 + }, + { + "description": "30 events/min for event kind ranges 5-7 and 43-49", + "kinds": [ + [ + 5, + 7 + ], + [ + 43, + 49 + ] + ], + "period": 60000, + "rate": 30 + }, + { + "description": "24 events/min for replaceable events and parameterized replaceable events", + "kinds": [ + [ + 10000, + 19999 + ], + [ + 30000, + 39999 + ] + ], + "period": 60000, + "rate": 24 + }, + { + "description": "60 events/min for Marmot group events (kind 445)", + "kinds": [ + 445 + ], + "period": 60000, + "rate": 60 + }, + { + "description": "60 events/min for ephemeral events", + "kinds": [ + [ + 20000, + 29999 + ] + ], + "period": 60000, + "rate": 60 + }, + { + "description": "720 events/hour for all events", + "period": 3600000, + "rate": 720 + } + ], + "whitelists": { + "pubkeys": [], + "ipAddresses": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "client": { + "subscription": { + "maxSubscriptions": 10, + "maxFilters": 10, + "maxFilterValues": 2500, + "maxSubscriptionIdLength": 256, + "maxLimit": 5000, + "minPrefixLength": 4 + } + }, + "message": { + "rateLimits": [ + { + "description": "240 raw messages/min", + "period": 60000, + "rate": 240 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "admin": { + "enabled": false, + "sessionTtlSeconds": 86400, + "nip98": { + "enabled": false, + "allowedPubkeys": [], + "maxSkewSeconds": 60 + }, + "notifications": { + "enabled": false, + "targets": [], + "events": { + "admission.invoice.created": true, + "admission.invoice.paid": true, + "admission.invoice.failed": true, + "settings.changed": true, + "relay.restarted": false + }, + "retry": { + "maxAttempts": 5, + "baseDelayMs": 1000 + }, + "deliveryLogRetentionDays": 30 + } + } + }, + "steps": [ + { + "connect": true, + "expect": [ + "[\"AUTH\",\"{{challenge}}\"]" + ] + }, + { + "send": { + "text": "[\"AUTH\",{\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"kind\":22242,\"created_at\":1700000000,\"content\":\"wire-session\",\"tags\":[[\"relay\",\"wss://nostream.your-domain.com\"],[\"challenge\",\"{{challenge}}\"]],\"id\":\"443c12a1e08a7cdefc611f639edae9d41a17b6514c045965b26881db8431296b\",\"sig\":\"b8bac964aaac4c83989a1c09fabbb409a159cfcfc4cf3b09223151f65b715f80d56507f1135b1724a9336ecc1c65b3e1d08162e2b410f6cee79a2a13bbf9a240\"}]" + }, + "expect": [ + "[\"OK\",\"443c12a1e08a7cdefc611f639edae9d41a17b6514c045965b26881db8431296b\",true,\"\"]" + ] + }, + { + "send": { + "text": "[\"REQ\",\"e1\",{\"kinds\":[4]}]" + }, + "expect": [ + "[\"EOSE\",\"e1\"]" + ] + }, + { + "send": { + "text": "[\"REQ\",\"e2\",{\"kinds\":[4]}]" + }, + "advance_ms": 1000, + "expect": [ + "[\"CLOSED\",\"e2\",\"auth-required: authentication is required to request these event kinds\"]" + ] + } + ], + "relay_secret": "683c29961bc5247d177a5019710ee3b776fb07ef636d834b848b4f726ace3f51", + "random": { + "challenge": "f6ab3ee2737d0dc52e7716eba23c41a74fe6c325bcd566b2019f56632e40238d", + "invite_code": null + } + }, + { + "id": "event-rejections", + "challenge": "g2CDxT6XrroywebAg2CQNh63eiUq2KGLVitGMZXVChQ", + "clock": { + "start_ms": 1700000000000 + }, + "seed": 20261023, + "settings": { + "info": { + "relay_url": "wss://nostream.your-domain.com", + "name": "nostream.your-domain.com", + "description": "A nostr relay written in Typescript.", + "banner": "https://nostream.your-domain.com/banner.png", + "icon": "https://nostream.your-domain.com/icon.png", + "pubkey": "replace-with-your-pubkey-in-hex", + "contact": "mailto:operator@your-domain.com", + "terms_of_service": "https://nostream.your-domain.com/terms" + }, + "payments": { + "enabled": false, + "processor": "zebedee", + "feeSchedules": { + "admission": [ + { + "enabled": false, + "description": "Admission fee charged per public key in msats (1000 msats = 1 satoshi)", + "amount": 1000000, + "whitelists": { + "pubkeys": [ + "replace-with-your-pubkey-in-hex" + ], + "event_kinds": [ + 9735 + ] + } + } + ] + }, + "invoiceExpirySeconds": 86400 + }, + "paymentsProcessors": { + "zebedee": { + "baseURL": "https://api.zebedee.io/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/zebedee", + "ipWhitelist": [ + "3.225.112.64", + "::ffff:3.225.112.64" + ] + }, + "lnbits": { + "baseURL": "https://lnbits.your-domain.com/", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/lnbits" + }, + "lnurl": { + "invoiceURL": "https://getalby.com/lnurlp/your-username" + }, + "nodeless": { + "baseURL": "https://nodeless.io", + "storeId": "your-nodeless-io-store-id" + }, + "opennode": { + "baseURL": "api.opennode.com", + "callbackBaseURL": "https://nostream.your-domain.com/callbacks/opennode" + }, + "nwc": { + "invoiceExpirySeconds": 900, + "replyTimeoutMs": 10000 + } + }, + "nip05": { + "mode": "disabled", + "verifyExpiration": 604800000, + "verifyUpdateFrequency": 86400000, + "maxConsecutiveFailures": 20, + "domainWhitelist": [], + "domainBlacklist": [] + }, + "nip42": { + "authRequired": false, + "sessionExpirySeconds": 0, + "restrictedReads": { + "enabled": false, + "kinds": [ + 4, + 1059 + ] + } + }, + "nip43": { + "enabled": true, + "inviteCodeExpirySeconds": 600, + "defaultMaxUses": 1, + "allowInviteRequests": false, + "inviteRequestWhitelist": [] + }, + "nip45": { + "enabled": true + }, + "nip50": { + "enabled": false, + "language": "simple", + "maxQueryLength": 256 + }, + "nip66": { + "enabled": false, + "probeIntervalSeconds": 3600, + "timeouts": { + "dnsMs": 10000, + "tlsMs": 10000, + "wsRttMs": 10000, + "nip11Ms": 10000 + }, + "targets": [], + "dnsCacheTtlSeconds": 300 + }, + "wot": { + "enabled": false, + "seedPubkey": "", + "minimumFollowers": 1, + "maxDepth": 2, + "refreshIntervalHours": 24 + }, + "nip56": { + "enabled": false, + "trustedModerators": [], + "hideActionableReports": false + }, + "network": { + "maxPayloadSize": 524288, + "trustedProxies": [ + "127.0.0.1", + "::ffff:127.0.0.1", + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "workers": { + "count": 0 + }, + "eventStore": { + "backend": "postgres" + }, + "mirroring": { + "static": [] + }, + "dvm": { + "workers": [] + }, + "limits": { + "rateLimiter": { + "strategy": "ewma" + }, + "invoice": { + "rateLimits": [ + { + "period": 60000, + "rate": 12 + }, + { + "period": 3600000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "invite": { + "rateLimits": [ + { + "description": "5 invite requests per hour per pubkey", + "period": 3600000, + "rate": 5 + } + ] + }, + "admissionCheck": { + "rateLimits": [ + { + "description": "30 admission checks/min or 1 check every 2 seconds", + "period": 60000, + "rate": 30 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "admin": { + "rateLimits": [ + { + "description": "30 admin requests/min", + "period": 60000, + "rate": 30 + } + ], + "loginRateLimits": [ + { + "description": "10 login attempts per 15 minutes", + "period": 900000, + "rate": 10 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + }, + "connection": { + "rateLimits": [ + { + "period": 1000, + "rate": 12 + }, + { + "period": 60000, + "rate": 48 + } + ], + "ipWhitelist": [ + "127.0.0.1", + "::1", + "::ffff:127.0.0.1" + ] + }, + "event": { + "retention": { + "maxDays": -1, + "kind": { + "whitelist": [ + 62 + ] + }, + "pubkey": { + "whitelist": [] + } + }, + "eventId": { + "minLeadingZeroBits": 4 + }, + "pow": { + "enabled": false, + "floorBits": 0, + "ceilingBits": 24, + "targetEventsPerSecond": 50, + "periodMs": 60000, + "wotThresholds": [] + }, + "kind": { + "whitelist": [], + "blacklist": [ + 7 + ] + }, + "pubkey": { + "minBalance": 0, + "minLeadingZeroBits": 1, + "whitelist": [], + "blacklist": [] + }, + "createdAt": { + "maxPositiveDelta": 60, + "maxNegativeDelta": 60 + }, + "content": [ + { + "description": "small", + "maxLength": 16 + } + ], + "rateLimits": [], + "whitelists": { + "pubkeys": [], + "ipAddresses": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "client": { + "subscription": { + "maxSubscriptions": 10, + "maxFilters": 10, + "maxFilterValues": 2500, + "maxSubscriptionIdLength": 256, + "maxLimit": 5000, + "minPrefixLength": 4 + } + }, + "message": { + "rateLimits": [ + { + "description": "240 raw messages/min", + "period": 60000, + "rate": 240 + } + ], + "ipWhitelist": [ + "::1", + "10.10.10.1", + "::ffff:10.10.10.1" + ] + } + }, + "admin": { + "enabled": false, + "sessionTtlSeconds": 86400, + "nip98": { + "enabled": false, + "allowedPubkeys": [], + "maxSkewSeconds": 60 + }, + "notifications": { + "enabled": false, + "targets": [], + "events": { + "admission.invoice.created": true, + "admission.invoice.paid": true, + "admission.invoice.failed": true, + "settings.changed": true, + "relay.restarted": false + }, + "retry": { + "maxAttempts": 5, + "baseDelayMs": 1000 + }, + "deliveryLogRetentionDays": 30 + } + } + }, + "steps": [ + { + "connect": true, + "expect": [ + "[\"AUTH\",\"{{challenge}}\"]" + ] + }, + { + "send": { + "text": "[\"EVENT\",{\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"kind\":1,\"created_at\":1700000000,\"content\":\"id-mismatch\",\"tags\":[],\"id\":\"63bd11bddf4ebccc7787651202de893a29915e6f6547ed4ab0f23e5f91514cef\",\"sig\":\"d671ad0142fdabe64f1f9b3bfad02be03b2e09a5923501153a67a6fcfed230418ec7030f0ee7fa23a862ee7c1050a555609749feb80f7d55f2ec122da69b0025\"}]" + }, + "expect": [ + "[\"OK\",\"63bd11bddf4ebccc7787651202de893a29915e6f6547ed4ab0f23e5f91514cef\",false,\"invalid: event id does not match\"]" + ] + }, + { + "send": { + "text": "[\"EVENT\",{\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"kind\":1,\"created_at\":1700000000,\"content\":\"sig\",\"tags\":[],\"id\":\"c29f373926a03d0ef3844edfc9ef5a17f1a7acad7702b73c37ab9705af747713\",\"sig\":\"00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\"}]" + }, + "expect": [ + "[\"OK\",\"c29f373926a03d0ef3844edfc9ef5a17f1a7acad7702b73c37ab9705af747713\",false,\"invalid: event signature verification failed\"]" + ] + }, + { + "send": { + "text": "[\"EVENT\",{\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"kind\":13,\"created_at\":1700000000,\"content\":\"seal\",\"tags\":[],\"id\":\"f8186979ed1921e9fd7d9e266e5999e9e2dacc4a25cacf68c40954ca7ea825a8\",\"sig\":\"fde5ec4620e258f7192b592a40eae65bf4e052c83e78080458f5bb650b0527126f9092f2df2bccbd643a1374652af3167a5bcbcdf6451b3b4521ab075b2f65fd\"}]" + }, + "expect": [ + "[\"OK\",\"f8186979ed1921e9fd7d9e266e5999e9e2dacc4a25cacf68c40954ca7ea825a8\",false,\"blocked: kind 13 events must not be published directly; wrap them in a kind 1059 gift wrap\"]" + ] + }, + { + "send": { + "text": "[\"EVENT\",{\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"kind\":444,\"created_at\":1700000000,\"content\":\"rumor\",\"tags\":[],\"id\":\"5a1dc8cd9085bdda3b7fe5e25abed4d5e00b645cbd39f69c2988b181ae9571a4\",\"sig\":\"bdb1318c29f9af45b14e2f2160bcd6d6733366ef63939ca9c135af5b9378383d86df85501753c54e7f5d661435e1489f6286466ee59545362b36f1c1a4f29d3e\"}]" + }, + "expect": [ + "[\"OK\",\"5a1dc8cd9085bdda3b7fe5e25abed4d5e00b645cbd39f69c2988b181ae9571a4\",false,\"blocked: kind 444 events must not be published directly; wrap them in a kind 1059 gift wrap\"]" + ] + }, + { + "send": { + "text": "[\"EVENT\",{\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"kind\":22242,\"created_at\":1700000000,\"content\":\"wire-session\",\"tags\":[],\"id\":\"d2cda445eb78312d1898c0ec6f6e41a42eb23c0388f61ab1adcd21ce0985ff97\",\"sig\":\"b26d92b0a15be1f0742232d22973e1cf0bc88110d4e3d95c5ed06791e7cd4fce2fbab4f8ac422796bb5649b73a0e445de82048732d54330e1f7982900667eb18\"}]" + }, + "expect": [ + "[\"OK\",\"d2cda445eb78312d1898c0ec6f6e41a42eb23c0388f61ab1adcd21ce0985ff97\",false,\"invalid: auth events must be sent using the AUTH message type\"]" + ] + }, + { + "send": { + "text": "[\"EVENT\",{\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"kind\":62,\"created_at\":1700000000,\"content\":\"wire-session\",\"tags\":[[\"relay\",\"wss://relay.example.com\"]],\"id\":\"0c368b154d7d2c33b2e27fe90424ce8bfc8aa0ef25383e334474fbdda043a5e6\",\"sig\":\"1e0e4009818d9f7281ea52ef1c0d3c070df5abec8ee6d50d59b3f46d3041a77b77c8cf85fea96fb687e8cd0bfed67a7ff42e94161c2c6290fbebcf31f492be90\"}]" + }, + "expect": [ + "[\"OK\",\"0c368b154d7d2c33b2e27fe90424ce8bfc8aa0ef25383e334474fbdda043a5e6\",false,\"invalid: request to vanish relay tag invalid\"]" + ] + }, + { + "send": { + "text": "[\"EVENT\",{\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"kind\":1,\"created_at\":1700000000,\"content\":\"wire-session\",\"tags\":[[\"expiration\",\"1699999999\"]],\"id\":\"6b9c4a9086e0d165c86f5576f07d8ffd5901282a885e3459dc94571e4adef06c\",\"sig\":\"d5c9b5059a250036fd28751ba27e27e3dedfc4d37f07189777c8f15450412b88da6f7d36b7e93db1e7764bab99484a49085d2f95e9b4efc3c3d0af67470ec0da\"}]" + }, + "expect": [ + "[\"OK\",\"6b9c4a9086e0d165c86f5576f07d8ffd5901282a885e3459dc94571e4adef06c\",false,\"event is expired\"]" + ] + }, + { + "send": { + "text": "[\"EVENT\",{\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"kind\":1,\"created_at\":1700000000,\"content\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"tags\":[],\"id\":\"0e287298784f82bb1f379ce7a803b7cf7d7b94427501642f12c2cedc38b114b8\",\"sig\":\"aeef7d6adac26427d7b645a43ea1d28509918e281c08de732bc54a31e9b71d22be56b340ddbe6fbde08790f116e99661ad1a0d8e3178f684bd27530ee2066f92\"}]" + }, + "expect": [ + "[\"OK\",\"0e287298784f82bb1f379ce7a803b7cf7d7b94427501642f12c2cedc38b114b8\",false,\"rejected: content is longer than 16 bytes\"]" + ] + }, + { + "send": { + "text": "[\"EVENT\",{\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"kind\":1,\"created_at\":1700003600,\"content\":\"wire-session\",\"tags\":[],\"id\":\"6a9b06d7bcb964c761ed3486b609bbbdac66d158bed3d73f2c6aba15651ab68e\",\"sig\":\"808a131207feb9d96c31cd49f8ffb47f97f6799bb50a34852845b6278d57586cc11dd749f01aa512de953f98a0e94b8d751a90d9a984d4518c6cafbe6961a705\"}]" + }, + "expect": [ + "[\"OK\",\"6a9b06d7bcb964c761ed3486b609bbbdac66d158bed3d73f2c6aba15651ab68e\",false,\"rejected: created_at is more than 60 seconds in the future\"]" + ] + }, + { + "send": { + "text": "[\"EVENT\",{\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"kind\":1,\"created_at\":1699996400,\"content\":\"wire-session\",\"tags\":[],\"id\":\"c0a730e53870d2dda20f1c754d67b0538d4005e2cd283221b33958aeef7752aa\",\"sig\":\"e1382aeefdaf97bbfe676d869c359eaf2ad84d4aeeb9adbf65b6e2faecdc2c58fae29079d86619f6c36effb4d900ef2ca7bf0465d15a2534d801d6dff2491428\"}]" + }, + "expect": [ + "[\"OK\",\"c0a730e53870d2dda20f1c754d67b0538d4005e2cd283221b33958aeef7752aa\",false,\"rejected: created_at is more than 60 seconds in the past\"]" + ] + }, + { + "send": { + "text": "[\"EVENT\",{\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"kind\":1,\"created_at\":1700000000,\"content\":\"not-ground\",\"tags\":[],\"id\":\"ae64486efac32cb7cd90c5799093f50b254a2eb3f694560fd2b10ee6986e7788\",\"sig\":\"11155a6b8c2ce4613f6f510dbbb85e080a48db1042c9d791eb37cfa82802c2ad4eb2e8e5a6a47e983aa7588cd233d8c804673fe65c7d548c84251680dec9a333\"}]" + }, + "expect": [ + "[\"OK\",\"ae64486efac32cb7cd90c5799093f50b254a2eb3f694560fd2b10ee6986e7788\",false,\"pow: difficulty 0<4\"]" + ] + }, + { + "send": { + "text": "[\"EVENT\",{\"pubkey\":\"32ded2ec00a8fb7d7bf26300abffe9f62d3f3b51f7b6b25659730e213734c994\",\"kind\":1,\"created_at\":1700000000,\"content\":\"ground-24\",\"tags\":[],\"id\":\"0fa35fc82bf887733cd5d4431930411c16a4f375f17c0b2578bda1a71c233639\",\"sig\":\"4496a4b7f2766371a4bc4a35a128ebd9b6f150b85f1764094eac4b1f33e180249dd3c81fa26a3281ff3a28e6feed94e02ed0fcb4a4bb09fe898fa519016155d5\"}]" + }, + "expect": [ + "[\"OK\",\"0fa35fc82bf887733cd5d4431930411c16a4f375f17c0b2578bda1a71c233639\",false,\"restricted: pubkey not admitted; send a join request (kind 28934) with a valid invite code\"]" + ] + }, + { + "send": { + "text": "[\"EVENT\",{\"pubkey\":\"32ded2ec00a8fb7d7bf26300abffe9f62d3f3b51f7b6b25659730e213734c994\",\"kind\":7,\"created_at\":1700000000,\"content\":\"ground-9\",\"tags\":[[\"e\",\"0000000000000000000000000000000000000000000000000000000000000000\"]],\"id\":\"0695cb4277139b7fa9075270f79317bc53b3923630301f741d31d5cef9af5737\",\"sig\":\"9fc42f5f14cd71c152eef210e73403da87ecda9526e449dc2b2db5655abc885663fe4b3a5b85a336aa1cfba6f0d74db2ae5cf13caab326801e913309e24e4b46\"}]" + }, + "expect": [ + "[\"OK\",\"0695cb4277139b7fa9075270f79317bc53b3923630301f741d31d5cef9af5737\",false,\"blocked: event kind 7 not allowed\"]" + ] + }, + { + "send": { + "text": "[\"EVENT\",{\"pubkey\":\"32ded2ec00a8fb7d7bf26300abffe9f62d3f3b51f7b6b25659730e213734c994\",\"kind\":1,\"created_at\":1700000000,\"content\":\"ground-8\",\"tags\":[[\"-\"]],\"id\":\"0d9cc8b63f099603bcf1ee0b589e6f67a12e8f52a072a61c10c5e5aac391644f\",\"sig\":\"59fd2f31bb795a3ef565c4ee7993a64927f6a368858a9049df519d688bb1c64573f166de50b283be01a5684244797de64611e14575a0010ac2874d8d999e736e\"}]" + }, + "expect": [ + "[\"OK\",\"0d9cc8b63f099603bcf1ee0b589e6f67a12e8f52a072a61c10c5e5aac391644f\",false,\"auth-required: this event may only be published by its author\"]" + ] + }, + { + "send": { + "text": "[\"EVENT\",{\"pubkey\":\"32ded2ec00a8fb7d7bf26300abffe9f62d3f3b51f7b6b25659730e213734c994\",\"kind\":1,\"created_at\":1700000000,\"content\":\"ground-28\",\"tags\":[[\"p\",\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\"]],\"id\":\"0c5aab1dcf5944fc3eafad380e462827d92f7fce910b53a1c72eb4d2f55b9f45\",\"sig\":\"75db6796dae152641444b8a110ba762345d6b967974d5b9f677f3b92e925c04fe3fbf05321072d094ebdf719421683c8e2001d526035ba4546e2d09a8bc7595c\"}]" + }, + "expect": [ + "[\"OK\",\"0c5aab1dcf5944fc3eafad380e462827d92f7fce910b53a1c72eb4d2f55b9f45\",false,\"restricted: pubkey not admitted; send a join request (kind 28934) with a valid invite code\"]" + ] + } + ], + "relay_secret": "683c29961bc5247d177a5019710ee3b776fb07ef636d834b848b4f726ace3f51", + "random": { + "challenge": "836083c53e97aeba32c1e6c0836090361eb77a252ad8a18b562b463195d50a14", + "invite_code": null + } + } + ] +} \ No newline at end of file diff --git a/crates/nostream-relay/tests/handlers.rs b/crates/nostream-relay/tests/handlers.rs index 84bc1dab0..2c04fdc0d 100644 --- a/crates/nostream-relay/tests/handlers.rs +++ b/crates/nostream-relay/tests/handlers.rs @@ -702,6 +702,25 @@ async fn the_default_strategy_answers_ok_and_duplicate() { assert_eq!(server.fixture.events.events().len(), 1); } +#[tokio::test] +async fn a_failed_insert_answers_with_the_generic_error() { + // `default-event-strategy.ts`: a create that throws reaches the handler's catch, which is + // the generic rejection rather than a stack trace. + let fixture = Fixture::new(settings()); + fixture.events.fail_create(true); + + let server = start(fixture).await; + let event = signed_event(&CLIENT_SECRET, 1, vec![], "unlucky", NOW_SECS); + + assert_eq!( + send_event(&server, &event).await, + format!( + r#"["OK","{}",false,"error: unable to process event"]"#, + event.id + ) + ); +} + #[tokio::test] async fn without_a_key_an_event_cannot_be_processed() { let server = Server::start_with( diff --git a/crates/nostream-relay/tests/transport.rs b/crates/nostream-relay/tests/transport.rs index 486b532a4..e8d606a70 100644 --- a/crates/nostream-relay/tests/transport.rs +++ b/crates/nostream-relay/tests/transport.rs @@ -294,6 +294,19 @@ async fn an_oversized_message_is_closed_with_1009() { } } +#[tokio::test] +async fn a_600kb_frame_against_a_524288_limit_closes_with_1009() { + let server = Server::start(Fixture::new(settings_with_payload(524_288))).await; + let (mut client, _challenge) = server.connect().await; + + client.send(&"x".repeat(600_000)).await; + + match client.next_message().await.expect("a close frame") { + Message::Close(Some(frame)) => assert_eq!(frame.code, CloseCode::Size), + other => panic!("expected a 1009 close, got {other:?}"), + } +} + #[tokio::test] async fn the_configured_limit_is_the_one_enforced() { // 1024 bytes: a text frame of 2000 bytes is refused. diff --git a/crates/nostream-relay/tests/ts_case_parity.rs b/crates/nostream-relay/tests/ts_case_parity.rs new file mode 100644 index 000000000..6ba81d159 --- /dev/null +++ b/crates/nostream-relay/tests/ts_case_parity.rs @@ -0,0 +1,279 @@ +//! R30's checked census: every unit case of the 22 in-scope TypeScript spec files must name the +//! Rust test that carries it. +//! +//! The two fixtures are `tests/fixtures/ts-cases.json` (the census, from +//! `tests/capture/gen-ts-cases.mjs`) and `tests/fixtures/ts-case-map.json` (the mapping). This +//! test fails when a census case has no mapping, when a mapped test function is absent from the +//! sources, when a mapped test is `#[ignore]`d, or when a per-file count disagrees with the +//! table the spec measured. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +/// The spec's Context table: each in-scope spec file and its measured case count. +const CENSUS: &[(&str, usize)] = &[ + ("test/unit/handlers/event-message-handler.spec.ts", 200), + ("test/unit/handlers/subscribe-message-handler.spec.ts", 58), + ("test/unit/handlers/count-message-handler.spec.ts", 10), + ("test/unit/handlers/auth-message-handler.spec.ts", 12), + ("test/unit/handlers/unsubscribe-message-handler.spec.ts", 1), + ( + "test/unit/handlers/event-strategies/default-event-strategy.spec.ts", + 4, + ), + ("test/unit/adapters/web-socket-adapter.spec.ts", 60), + ("test/unit/adapters/web-socket-server-adapter.spec.ts", 17), + ("test/unit/utils/nip42.spec.ts", 27), + ("test/unit/utils/nip42-session.spec.ts", 9), + ("test/unit/utils/messages.spec.ts", 14), + ("test/unit/utils/filter.spec.ts", 9), + ("test/unit/utils/ewma-rate-limiter.spec.ts", 6), + ("test/unit/utils/sliding-window-rate-limiter.spec.ts", 3), + ("test/unit/factories/event-strategy-factory.spec.ts", 25), + ("test/unit/factories/message-handler-factory.spec.ts", 6), + ("test/unit/factories/websocket-adapter-factory.spec.ts", 1), + ("test/unit/schemas/event-schema.spec.ts", 57), + ("test/unit/schemas/message-schema.spec.ts", 17), + ("test/unit/utils/hidden-content-cache.spec.ts", 12), + ("test/unit/utils/shutdown-state.spec.ts", 5), + ("test/unit/utils/validation.spec.ts", 4), +]; + +/// The capture commit the census was recorded from. +const CAPTURE: &str = "242a3001d77149e4d70151ec03060c4ef44a1547"; + +fn fixture(name: &str) -> Value { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(name); + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("{}: {error}", path.display())); + + serde_json::from_str(&text).unwrap_or_else(|error| panic!("{}: {error}", path.display())) +} + +/// The workspace root: two directories above this crate. +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("the crate lives two levels below the workspace root") + .to_path_buf() +} + +/// Whether `source` defines `fn name` and, if it does, whether that function is `#[ignore]`d. +fn test_function(source: &str, name: &str) -> Option { + let needle = format!("fn {name}("); + let lines: Vec<&str> = source.lines().collect(); + let index = lines.iter().position(|line| line.contains(&needle))?; + + // Walk up over the attribute and doc-comment block that decorates the function. + let mut ignored = false; + let mut above = index; + + while above > 0 { + let line = lines[above - 1].trim(); + + if line.starts_with("#[") || line.starts_with("///") || line.starts_with("//") { + if line.starts_with("#[ignore") { + ignored = true; + } + + above -= 1; + continue; + } + + break; + } + + // A one-line declaration (`fn name() { … }`) must still end the walk at a declaration. + Some(ignored) +} + +#[test] +fn the_census_counts_are_the_tables() { + let census = fixture("ts-cases.json"); + + assert_eq!( + census["capture"].as_str(), + Some(CAPTURE), + "the census records its capture commit" + ); + + let table: BTreeMap<&str, usize> = CENSUS.iter().copied().collect(); + let mut counted = BTreeMap::new(); + let mut total = 0; + + for file in census["files"].as_array().expect("files") { + let name = file["file"].as_str().expect("file"); + let cases = file["cases"].as_u64().expect("cases") as usize; + let titles = file["titles"].as_array().expect("titles").len(); + + assert_eq!( + titles, cases, + "{name}: the case count and the titles disagree" + ); + assert_eq!( + table.get(name).copied(), + Some(cases), + "{name}: {cases} cases, the Context table says {:?}", + table.get(name) + ); + + counted.insert(name.to_owned(), cases); + total += cases; + } + + assert_eq!( + counted.keys().len(), + table.keys().len(), + "the census covers a different file set than the table: {counted:?}" + ); + assert_eq!(total, 557, "the census is 557 cases"); +} + +#[test] +fn every_case_is_mapped_or_explicitly_exempt() { + let census = fixture("ts-cases.json"); + let map = fixture("ts-case-map.json"); + + // The capture has two cases with the same title in the deprecated `maxLength` group, so a + // file's titles are compared as an ordered-by-sort multiset rather than a set. + let mut expected: BTreeMap> = BTreeMap::new(); + + for file in census["files"].as_array().expect("files") { + let name = file["file"].as_str().expect("file").to_owned(); + let mut titles: Vec = file["titles"] + .as_array() + .expect("titles") + .iter() + .map(|title| title.as_str().expect("title").to_owned()) + .collect(); + + titles.sort(); + expected.insert(name, titles); + } + + let mut mapped: BTreeMap> = BTreeMap::new(); + + for group in map["groups"].as_array().expect("groups") { + let file = group["file"].as_str().expect("file").to_owned(); + + assert!( + expected.contains_key(&file), + "the map names a file outside the census: {file}" + ); + assert!( + group["test"].is_string() || group["exempt"].is_string(), + "a group must name a test or say why it is exempt: {group}" + ); + + for title in group["titles"].as_array().expect("titles") { + mapped + .entry(file.clone()) + .or_default() + .push(title.as_str().expect("title").to_owned()); + } + } + + for titles in mapped.values_mut() { + titles.sort(); + } + + for (file, titles) in &expected { + let found = mapped.get(file).cloned().unwrap_or_default(); + + if &found != titles { + let missing: Vec<&String> = titles + .iter() + .filter(|title| !found.contains(title)) + .collect(); + let extra: Vec<&String> = found + .iter() + .filter(|title| !titles.contains(title)) + .collect(); + + panic!("{file}: unmapped {missing:?}, mapped but not in the census {extra:?}"); + } + } + + assert_eq!( + mapped.keys().len(), + expected.keys().len(), + "the map covers every census file" + ); +} + +#[test] +fn every_mapped_test_exists_and_is_not_ignored() { + let map = fixture("ts-case-map.json"); + let root = workspace_root(); + + let mut checked = 0; + let mut exempt = 0; + let mut titles_in_exempt = 0; + + for group in map["groups"].as_array().expect("groups") { + let titles = group["titles"].as_array().expect("titles").len(); + + let Some(target) = group["test"].as_str() else { + let reason = group["exempt"].as_str().expect("exempt reason"); + + assert!( + reason.contains("job 0"), + "an exemption names the job that carries it: {reason}" + ); + + exempt += 1; + titles_in_exempt += titles; + continue; + }; + + let (path, function) = target + .split_once("::") + .unwrap_or_else(|| panic!("a mapping is `::`: {target}")); + let file = root.join(path); + + assert!( + file.exists(), + "{}: the mapped file is absent", + file.display() + ); + + let source = std::fs::read_to_string(&file).expect("the mapped source"); + + match test_function(&source, function) { + Some(false) => checked += 1, + Some(true) => panic!("{target} is #[ignore]d, so it carries nothing"), + None => panic!("{target}: no such test function in {}", file.display()), + } + } + + assert!(checked > 0, "the map names no tests at all"); + + println!( + "census: {} cases, {} mapped to {} live tests, {} exempt groups holding {} cases", + map["cases"].as_u64().unwrap_or(0), + map["cases"].as_u64().unwrap_or(0) as usize - titles_in_exempt, + checked, + exempt, + titles_in_exempt + ); +} + +#[test] +fn the_map_records_the_census_it_was_built_from() { + let census = fixture("ts-cases.json"); + let map = fixture("ts-case-map.json"); + + assert_eq!(map["capture"].as_str(), census["capture"].as_str()); + assert_eq!( + map["cases"].as_u64(), + Some(557), + "the map covers the whole census" + ); +} diff --git a/crates/nostream-relay/tests/wire_parity.rs b/crates/nostream-relay/tests/wire_parity.rs new file mode 100644 index 000000000..eb5ddbcc2 --- /dev/null +++ b/crates/nostream-relay/tests/wire_parity.rs @@ -0,0 +1,908 @@ +//! R31/R32's differential oracle: every recorded frame, replayed against the Rust relay and +//! compared as raw UTF-8 text in order. +//! +//! The fixture is `tests/fixtures/wire-sessions.json`, recorded by +//! `tests/capture/gen-wire-sessions.mjs` from the capture's real adapters and handlers. Each +//! session carries the settings document, the frozen clock, the fixture seed, the server-minted +//! values the recording depended on, and the frames the capture's relay sent for each client +//! frame. +//! +//! Comparison semantics: byte-identical after `{{challenge}}` substitution, with only the two +//! declared tolerances (T1 the detail after `invalid: ` for unparseable JSON, T2 a close frame's +//! reason when its code matches). Every session's `allow` list is summarised at the end of the +//! run; anything undeclared fails. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod common; + +use std::collections::BTreeMap; +use std::net::SocketAddr; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use nostream_config::settings::Settings; +use nostream_core::clock::{Clock, TestClock}; +use nostream_core::random::RecordedRandom; +use nostream_relay::relay::{Relay, RelayConfig}; +use nostream_relay::relay_key::RelayKeySource; +use nostream_relay::testing::Fixture; +use nostream_storage::repositories::{ActionableTarget, User}; +use serde_json::Value; +use tokio_tungstenite::tungstenite::Message; + +const PLACEHOLDER: &str = "{{challenge}}"; + +fn fixture() -> Value { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/wire-sessions.json"); + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("{}: {error}", path.display())); + + serde_json::from_str(&text).unwrap_or_else(|error| panic!("{}: {error}", path.display())) +} + +/// The recorded reason for a tolerated difference. +#[derive(Debug, Clone)] +struct Allow { + why: String, +} + +impl Allow { + fn permits_json_detail(&self) -> bool { + self.why == "T1" + } + + fn permits_close_reason(&self) -> bool { + self.why == "T2" + } +} + +/// One recorded step: a client frame, what the relay answered, and the declared tolerances. +#[derive(Debug, Clone, Default)] +struct Step { + connect: bool, + text: Option, + expect: Vec, + allow: Vec, + advance_ms: u64, + none: bool, + drain: bool, + compare: bool, + compare_why: Option, + oversized: Option, +} + +fn parse_step(value: &Value) -> Step { + Step { + connect: value["connect"].as_bool().unwrap_or(false), + text: value["send"]["text"].as_str().map(str::to_owned), + expect: value["expect"] + .as_array() + .map(|frames| { + frames + .iter() + .map(|frame| frame.as_str().expect("a frame").to_owned()) + .collect() + }) + .unwrap_or_default(), + allow: value["allow"] + .as_array() + .map(|allows| { + allows + .iter() + .map(|allow| Allow { + why: allow["why"].as_str().expect("a tolerance").to_owned(), + }) + .collect() + }) + .unwrap_or_default(), + advance_ms: value["advance_ms"].as_u64().unwrap_or(0), + none: value["send"].is_null() && value["drain"].is_null() && value["connect"].is_null(), + drain: value["drain"].as_bool().unwrap_or(false), + compare: value["compare"].as_bool().unwrap_or(true), + compare_why: value["compare_why"].as_str().map(str::to_owned), + oversized: value["oversized"].as_u64().map(|size| size as usize), + } +} + +/// One recorded session. +struct Session { + id: String, + settings: Settings, + start_ms: u64, + liveness_interval_ms: Option, + challenge: Option, + random: Option<(String, Option)>, + relay_secret: Option, + offer_compression: bool, + steps: Vec, + final_close: Option<(u16, String)>, + actionable: Vec, + vanished: Vec, + blocked: Vec, + seeded_events: Vec, + skipped: bool, +} + +fn parse_session(value: &Value) -> Session { + let settings: Settings = serde_json::from_value(value["settings"].clone()) + .unwrap_or_else(|error| panic!("{}: settings: {error}", value["id"])); + + let final_close = value["final_close"].as_object().map(|close| { + let code = match &close["code"] { + Value::Number(number) => number.as_u64().unwrap_or(1005) as u16, + _ => 1005, + }; + + ( + code, + close["reason"].as_str().unwrap_or_default().to_owned(), + ) + }); + + Session { + id: value["id"].as_str().expect("a session id").to_owned(), + settings, + start_ms: value["clock"]["start_ms"] + .as_u64() + .unwrap_or(1_700_000_000_000), + liveness_interval_ms: value["liveness_interval_ms"].as_u64(), + challenge: value["challenge"].as_str().map(str::to_owned), + random: value["random"].as_object().map(|random| { + ( + random["challenge"].as_str().unwrap_or_default().to_owned(), + random["invite_code"].as_str().map(str::to_owned), + ) + }), + relay_secret: value["relay_secret"].as_str().map(str::to_owned), + offer_compression: value["handshake"].is_object(), + steps: value["steps"] + .as_array() + .expect("steps") + .iter() + .map(parse_step) + .collect(), + final_close, + actionable: value["actionable"] + .as_array() + .map(|targets| { + targets + .iter() + .map(|target| ActionableTarget { + reported_pubkey: target["reportedPubkey"] + .as_str() + .and_then(|text| text.parse().ok()), + reported_event_id: target["reportedEventId"] + .as_str() + .and_then(|text| text.parse().ok()), + }) + .collect() + }) + .unwrap_or_default(), + vanished: pubkeys(value["vanished"].as_array()), + blocked: pubkeys(value["blocked"].as_array()), + seeded_events: Vec::new(), + skipped: value["comparison"].as_str() == Some("skipped"), + } +} + +fn pubkeys(values: Option<&Vec>) -> Vec { + values + .map(|values| { + values + .iter() + .filter_map(|value| value.as_str()) + .filter_map(|text| text.parse().ok()) + .collect() + }) + .unwrap_or_default() +} + +/// A relay bound to an ephemeral port, with the session's wiring. +struct Server { + address: SocketAddr, + clock: Arc, + shutdown: nostream_core::lifecycle::ShutdownState, +} + +impl Server { + async fn start(session: &Session, random: Arc) -> Self { + let fixture = Fixture::new(session.settings.clone()); + + for event in &session.seeded_events { + fixture.seed_event(event.clone()).await; + } + + for pubkey in &session.vanished { + let mut user = user(*pubkey); + + user.is_vanished = true; + fixture.users.insert(user); + } + + for pubkey in &session.blocked { + fixture.users.insert(user(*pubkey)); + } + + fixture.reports.set_targets(session.actionable.clone()); + + let clock = Arc::new(TestClock::new(session.start_ms)); + let keys = match &session.relay_secret { + Some(secret) => RelayKeySource::from_values(Some(secret.clone()), None), + None => RelayKeySource::from_values(None, None), + }; + + let config = RelayConfig { + liveness_interval: Duration::from_millis( + session.liveness_interval_ms.unwrap_or(600_000), + ), + hidden_refresh_interval: Duration::from_secs(600), + drain_timeout: Duration::from_millis(300), + }; + + let relay = Relay::with_keys( + session.settings.clone(), + Arc::clone(&fixture.stores), + Arc::clone(&clock) as Arc, + keys, + ) + .with_config(config) + .with_random(random); + + // The relay's boot refresh seeds its mirror from the repository, exactly as the + // capture's `startHiddenContentCache` does, so the targets go to the double. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let address = listener.local_addr().expect("local address"); + let shutdown = nostream_core::lifecycle::ShutdownState::new(); + let connection_shutdown = shutdown.clone(); + let served = Arc::clone(&relay); + + tokio::spawn(async move { + let _ = served.serve(listener, connection_shutdown).await; + }); + + Self { + address, + clock, + shutdown, + } + } +} + +fn user(pubkey: nostream_core::Pubkey) -> User { + let now = chrono::DateTime::from_timestamp(1_700_000_000, 0).expect("a timestamp"); + + User { + pubkey, + is_admitted: false, + is_vanished: false, + balance: 0, + created_at: now, + updated_at: now, + } +} + +/// The frames a session's replay produced, with the relay's challenge masked. +struct Replay { + frames: Vec, + allow: Vec, +} + +impl Replay { + fn compare(&self, expected: &[String], step: &Step, session: &str) { + let mut observed = self.frames.iter(); + + for (index, want) in expected.iter().enumerate() { + let got = observed.next().unwrap_or_else(|| { + panic!( + "{session}: step {:?} expected {} frames, got {}", + step.text, + expected.len(), + self.frames.len() + ) + }); + + if got == want { + continue; + } + + if step.allow.iter().any(Allow::permits_json_detail) + && want.starts_with(r#"["NOTICE","invalid: "#) + && got.starts_with(r#"["NOTICE","invalid: "#) + { + continue; + } + + panic!( + "{session}: step {:?} frame {index}\n expected: {want}\n observed: {got}", + step.text + ); + } + + assert_eq!( + self.frames.len(), + expected.len(), + "{session}: step {:?} sent more frames than recorded: {:?}", + step.text, + self.frames + ); + } +} + +/// Replays one session. +async fn replay(session: &Session, random: Arc) -> BTreeMap { + let server = Server::start(session, random).await; + let mut counts = BTreeMap::new(); + + if session.offer_compression { + // A raw handshake that offers the extension: the response must decline it, and the + // whole session runs over this socket, because the offer is the point of it. + let (mut raw, response) = raw_handshake(server.address).await; + + assert!( + response.starts_with("HTTP/1.1 101"), + "{}: {response}", + session.id + ); + assert!( + !response + .to_ascii_lowercase() + .contains("sec-websocket-extensions"), + "{}: the offer must be declined: {response}", + session.id + ); + + let auth = raw.read_frame().await.expect("the AUTH frame"); + let challenge = auth + .text() + .strip_prefix(r#"["AUTH",""#) + .and_then(|rest| rest.strip_suffix(r#""]"#)) + .expect("a challenge") + .to_owned(); + + assert!(!auth.rsv1, "{}: uncompressed", session.id); + + if let Some(recorded) = &session.challenge { + assert_eq!(&challenge, recorded, "{}: the challenge", session.id); + } + + for step in &session.steps { + if step.connect { + continue; + } + + let text = step + .text + .as_deref() + .expect("a frame") + .replace(PLACEHOLDER, &challenge); + + raw.send_text(&text).await; + + let mut frames = Vec::new(); + for _ in 0..step.expect.len() { + let frame = raw.read_frame().await.expect("a frame"); + + assert!(!frame.rsv1, "{}: uncompressed frame", session.id); + frames.push(mask(&frame.text(), &challenge)); + } + + assert_eq!(frames, step.expect, "{}: step {:?}", session.id, text); + } + + server.shutdown.begin_draining(); + counts.insert("steps".to_owned(), session.steps.len()); + counts.insert("allow".to_owned(), 0); + + return counts; + } + + let (mut client, response) = tokio::time::timeout( + Duration::from_secs(5), + tokio_tungstenite::connect_async(format!("ws://{}/", server.address)), + ) + .await + .expect("connect within five seconds") + .expect("handshake"); + + if session.offer_compression { + assert!( + !response.headers().contains_key("sec-websocket-extensions"), + "{}: the library offer was declined", + session.id + ); + } + + let mut challenge = String::new(); + let mut allow = Vec::new(); + let mut steps = 0; + + for step in &session.steps { + if step.connect { + let frame = next_text(&mut client).await; + + challenge = frame + .strip_prefix(r#"["AUTH",""#) + .and_then(|rest| rest.strip_suffix(r#""]"#)) + .expect("the challenge") + .to_owned(); + + if let Some(recorded) = &session.challenge { + assert_eq!( + &challenge, recorded, + "{}: the replayed challenge is the recorded one", + session.id + ); + } + + for expected in &step.expect { + assert_eq!( + &mask(&frame, &challenge), + expected, + "{}: the first frame is the challenge", + session.id + ); + } + + steps += 1; + continue; + } + + if let Some(size) = step.oversized { + // One frame past the limit: the relay answers with a close frame, compared below. + client + .send(Message::Text("x".repeat(size).into())) + .await + .expect("send the oversized frame"); + steps += 1; + continue; + } + + if step.advance_ms > 0 { + server.clock.advance(step.advance_ms); + } + + if step.drain { + server.shutdown.begin_draining(); + + let replay = collect(&mut client, step, &challenge).await; + + if !session.skipped { + replay.compare(&step.expect, step, &session.id); + } + + allow.extend(replay.allow); + steps += 1; + continue; + } + + let text = step + .text + .as_deref() + .expect("a step sends a frame") + .replace(PLACEHOLDER, &challenge); + + client + .send(Message::Text(text.clone().into())) + .await + .unwrap_or_else(|error| panic!("{}: send: {error}", session.id)); + + if step.none { + tokio::time::sleep(Duration::from_millis(250)).await; + + let silent = tokio::time::timeout(Duration::from_millis(1), client.next()).await; + + assert!( + silent.is_err(), + "{}: step {:?} promised no frame", + session.id, + text + ); + steps += 1; + continue; + } + + let replay = collect(&mut client, step, &challenge).await; + + if !session.skipped && step.compare { + replay.compare(&step.expect, step, &session.id); + } else if let Some(why) = &step.compare_why { + println!("{}: not compared — {why}", session.id); + } + + allow.extend(replay.allow); + counts.insert(format!("step{steps}"), replay.frames.len()); + steps += 1; + } + + if let Some((code, reason)) = &session.final_close { + if *code == 1005 { + // The capture's relay drops the socket without a close frame: read until the + // stream ends, and a close frame is a failure. + let deadline = std::time::Instant::now() + Duration::from_secs(5); + + loop { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + + match tokio::time::timeout(remaining, client.next()).await { + Ok(None) | Ok(Some(Err(_))) | Err(_) => break, + Ok(Some(Ok(Message::Close(frame)))) => { + panic!( + "{}: closed with a frame {frame:?}, not silently", + session.id + ) + } + Ok(Some(Ok(_))) => continue, + } + } + } else { + let closed = tokio::time::timeout(Duration::from_secs(5), client.next()) + .await + .unwrap_or_else(|_| panic!("{}: a close frame, not silence", session.id)) + .unwrap_or_else(|| panic!("{}: the socket ended without a frame", session.id)); + + match closed { + Ok(Message::Close(Some(frame))) => { + let observed = u16::from(frame.code); + + assert_eq!(observed, *code, "{}: close code", session.id); + if !allow.iter().any(Allow::permits_close_reason) && !reason.is_empty() { + assert_eq!( + frame.reason.as_str(), + reason, + "{}: close reason", + session.id + ); + } + } + other => panic!( + "{}: expected close {code} {:?}, got {other:?}", + session.id, reason + ), + } + } + } + + server.shutdown.begin_draining(); + counts.insert("steps".to_owned(), steps); + counts.insert("allow".to_owned(), allow.len()); + counts +} + +/// Reads `step.expect.len()` frames, masking the challenge in each. +async fn collect( + client: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + step: &Step, + challenge: &str, +) -> Replay { + let mut frames = Vec::new(); + + for _ in 0..step.expect.len() { + let frame = next_text(client).await; + + frames.push(mask(&frame, challenge)); + } + + Replay { + frames, + allow: step.allow.clone(), + } +} + +fn hex_to_bytes(hex: &str) -> Vec { + (0..hex.len()) + .step_by(2) + .map(|index| u8::from_str_radix(&hex[index..index + 2], 16).expect("hex")) + .collect() +} + +fn mask(frame: &str, challenge: &str) -> String { + frame.replace(challenge, PLACEHOLDER) +} + +async fn next_text( + client: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, +) -> String { + let message = tokio::time::timeout(Duration::from_secs(5), client.next()) + .await + .expect("a frame within five seconds") + .expect("a message"); + + match message { + Ok(Message::Text(text)) => text.as_str().to_owned(), + other => panic!("expected a text frame, got {other:?}"), + } +} + +// --------------------------------------------------------------------------------------- +// A raw handshake, for the compression offer the websocket library will not make. +// --------------------------------------------------------------------------------------- + +struct Raw { + stream: tokio::net::TcpStream, + buffered: Vec, +} + +#[derive(Debug)] +struct RawFrame { + rsv1: bool, + payload: Vec, +} + +impl RawFrame { + fn text(&self) -> String { + String::from_utf8(self.payload.clone()).expect("utf8") + } +} + +async fn raw_handshake(address: SocketAddr) -> (Raw, String) { + use tokio::io::AsyncWriteExt; + + let mut stream = tokio::net::TcpStream::connect(address) + .await + .expect("connect"); + let request = format!( + "GET / HTTP/1.1\r\nHost: {address}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\ + Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\ + Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits\r\n\r\n" + ); + + stream + .write_all(request.as_bytes()) + .await + .expect("write handshake"); + + let mut raw = Raw { + stream, + buffered: Vec::new(), + }; + let head = raw.read_until_headers_end().await; + + (raw, head) +} + +impl Raw { + /// Sends one masked client text frame. + async fn send_text(&mut self, text: &str) { + use tokio::io::AsyncWriteExt; + + let payload = text.as_bytes(); + let mask = [0x12u8, 0x34, 0x56, 0x78]; + let mut frame = Vec::with_capacity(payload.len() + 14); + + frame.push(0x81); + + if payload.len() < 126 { + frame.push(0x80 | payload.len() as u8); + } else { + frame.push(0x80 | 126); + frame.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + } + + frame.extend_from_slice(&mask); + + for (index, byte) in payload.iter().enumerate() { + frame.push(byte ^ mask[index % 4]); + } + + self.stream.write_all(&frame).await.expect("write frame"); + } + + async fn read_until_headers_end(&mut self) -> String { + use tokio::io::AsyncReadExt; + + loop { + if let Some(end) = find(&self.buffered, b"\r\n\r\n") { + let head = String::from_utf8(self.buffered[..end].to_vec()).expect("utf8 head"); + self.buffered.drain(..end + 4); + + return head; + } + + let mut buffer = [0u8; 4096]; + let read = self.stream.read(&mut buffer).await.expect("read"); + + assert!(read > 0, "the handshake response ended early"); + self.buffered.extend_from_slice(&buffer[..read]); + } + } + + async fn read_frame(&mut self) -> Option { + use tokio::io::AsyncReadExt; + + loop { + if let Some(frame) = self.parse_frame() { + return Some(frame); + } + + let mut buffer = [0u8; 4096]; + let read = self.stream.read(&mut buffer).await.expect("read"); + + if read == 0 { + return None; + } + + self.buffered.extend_from_slice(&buffer[..read]); + } + } + + fn parse_frame(&mut self) -> Option { + let header = self.buffered.first().copied()?; + + if self.buffered.len() < 2 { + return None; + } + + let second = self.buffered[1]; + let mut length = u64::from(second & 0x7f); + let mut offset = 2; + + if length == 126 { + let bytes = self.buffered.get(2..4)?; + length = u64::from(u16::from_be_bytes([bytes[0], bytes[1]])); + offset = 4; + } else if length == 127 { + let bytes = self.buffered.get(2..10)?; + length = u64::from_be_bytes([ + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + ]); + offset = 10; + } + + let length = usize::try_from(length).expect("a length that fits"); + let payload = self.buffered.get(offset..offset + length)?.to_vec(); + + self.buffered.drain(..offset + length); + + Some(RawFrame { + rsv1: header & 0x40 != 0, + payload, + }) + } +} + +fn find(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +// --------------------------------------------------------------------------------------- +// The oracle +// --------------------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread")] +async fn every_recorded_session_replays_frame_for_frame() { + let fixture = fixture(); + let seed = fixture["seed"].as_u64().expect("a seed"); + + assert_eq!( + fixture["clock"]["start_ms"].as_u64(), + Some(1_700_000_000_000), + "the recording declares its frozen clock" + ); + + // Each session carries the bytes its relay minted; a fresh source per session replays them, + // so the fixture does not depend on a generator's position in a shared stream. + let _ = seed; + let sessions: Vec = fixture["sessions"] + .as_array() + .expect("sessions") + .iter() + .map(parse_session) + .collect(); + + let ids: Vec<&str> = sessions.iter().map(|session| session.id.as_str()).collect(); + for required in [ + "measured-session", + "rate-limit-message", + "rate-limit-event", + "whitelists", + "req-auth-required", + "req-close-abort", + "invite-mint", + "live-dispatch", + "hidden-report", + "no-secret", + "drain", + "liveness", + "compression-offer", + "count-disabled", + "nip42-expiry", + "event-rejections", + ] { + assert!( + ids.contains(&required), + "the fixture is missing the {required} session: {ids:?}" + ); + } + + let mut tolerances = 0; + let mut frames = 0; + + for session in &sessions { + let mut draws = Vec::new(); + + if let Some((challenge, invite_code)) = &session.random { + draws.push(hex_to_bytes(challenge)); + + if let Some(code) = invite_code { + draws.push(hex_to_bytes(code)); + } + } + + let counts = replay(session, Arc::new(RecordedRandom::new(draws))).await; + + tolerances += counts.get("allow").copied().unwrap_or(0); + frames += counts.get("steps").copied().unwrap_or(0); + + println!( + "{}: {} steps{}", + session.id, + counts.get("steps").copied().unwrap_or(0), + if session.skipped { + " (declared not compared)" + } else { + "" + } + ); + } + + println!( + "{frames} steps over {} sessions, {tolerances} declared tolerances", + sessions.len() + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn every_declared_tolerance_is_one_of_the_two() { + let fixture = fixture(); + let mut tolerances = Vec::new(); + + for session in fixture["sessions"].as_array().expect("sessions") { + for step in session["steps"].as_array().expect("steps") { + for allow in step["allow"].as_array().into_iter().flatten() { + let why = allow["why"].as_str().expect("a tolerance").to_owned(); + + assert!( + why == "T1" || why == "T2", + "{}: undeclared tolerance {why}", + session["id"] + ); + tolerances.push(why); + } + } + } + + println!("declared tolerances: {tolerances:?}"); + + let mut t1 = 0; + let mut t2 = 0; + for why in &tolerances { + if why == "T1" { + t1 += 1; + } else { + t2 += 1; + } + } + + assert_eq!(t1 + t2, tolerances.len()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_fixture_seeds_the_client_key_the_frames_were_signed_with() { + let fixture = fixture(); + let secret = fixture["client_secret"].as_str().expect("a client secret"); + let first = &fixture["sessions"][0]; + + assert_eq!(first["clock"]["start_ms"].as_u64(), Some(1_700_000_000_000)); + assert_eq!(secret.len(), 64, "a secp256k1 secret in hex"); +} diff --git a/docs/rewrite/decisions/0004-wire-transport.md b/docs/rewrite/decisions/0004-wire-transport.md new file mode 100644 index 000000000..f3dc2b64f --- /dev/null +++ b/docs/rewrite/decisions/0004-wire-transport.md @@ -0,0 +1,133 @@ +# ADR 0004: The wire transport and the differential fixture (job 008) + +- **Status:** accepted (job 008, Phase 2 slice 2) +- **Deciders:** the rewrite team, on the evidence in job 008's recon and spec +- **Date:** 2026-10-23 + +## Context + +The relay crate was a stub map: the daemon accepted a socket and dropped it, the +transport was a unit struct, and the five message handlers had no parameters. Job 008 +ports the wire: the handshake, the per-socket state machine, the five handlers, the +rate limiters, live dispatch, the drain, and the two oracles that decide whether the +port is faithful — a checked census of the capture's 557 unit cases and a recorded +frame-session fixture replayed against the Rust relay. + +This ADR records the decisions the code does not show on its face. Each heading is +greppable. + +## Transport — `tokio-tungstenite` 0.30, compression declined + +The transport is `tokio-tungstenite` 0.30 (`connect`, `handshake`), whose +`WebSocketConfig` bounds both a frame and a reassembled message, so a fragmented message +cannot grow past `network.maxPayloadSize` either. No feature of that crate offers +`permessage-deflate`; implementing the extension by hand would be a project of its own +for a wire the capture runs with compression off (`perMessageDeflate: false` in its +server adapter). A client's offer is therefore declined: the 101 response carries no +`Sec-WebSocket-Extensions`, and every frame is uncompressed. `tests/transport.rs:: +a_declined_compression_offer_leaves_the_session_uncompressed` and the fixture's +`compression-offer` session hold that boundary; an approver who wants the extension +itself should scope a job for it. + +The write path is one function. `transport::send_text` is the only place a string +becomes outbound bytes, `transport::classify` the only place an inbound `tungstenite` +message is matched, so a second write path cannot appear unnoticed. + +## Rate limiters — the in-process store, behind a trait + +The capture reaches Redis through module-level singletons built from +`rateLimiterFactory`. Two windows exist and their scripts differ in ways that matter: +the sliding window records a hit only when it is allowed, the EWMA records it even when +it refuses, and an EWMA key expires at `ceil(period / 1000)` seconds while a sliding +entry drops inclusively at `now - period`. Both are implemented in process over a +`Mutex`, behind a `Limiter` trait, because the behaviour under test is the window's +arithmetic and the wiring, not the transport to a shared store. A store error is +fail-closed: the event is answered `rate-limited: slow down` with no backoff, which is +what the capture's catch does. A later job that wants a shared store implements +`Limiter`; nothing in the handlers changes. + +`RateLimits` keeps the four levels (`connection`, `message`, `event`, `invite`) and +their whitelists, and `Limiter` is injectable so the failure path is exercised rather +than asserted by inspection. + +## Determinism seams — the clock and the fixture's randomness + +Two values on this wire are not derived from anything the client sends: the current +time and the bytes the relay itself mints. Time is `nostream_core::clock::Clock`, with +`SystemClock` in the daemon and `TestClock` in tests; no handler reads `SystemTime` or +`Instant` (AC37's grep is empty). + +Randomness is `nostream_core::random::RandomSource`, for the same reason and with the +same shape: `SystemRandom` in the daemon, `SeededRandom` (SplitMix64) and +`RecordedRandom` in fixtures. The capture mints the NIP-42 challenge (32 bytes) and a +NIP-43 invite code (16 bytes) from `crypto.randomBytes`, and the AUTH event's id and +signature are computed over the challenge, so a replay that substituted a different +challenge could not verify. The generator records the bytes its relay minted, and the +replay hands the relay exactly those, which keeps `{{challenge}}` a placeholder in the +recorded text while proving the relay verifies *its own* challenge rather than a +constant. + +Signing draws no randomness in the generator: BIP-340 takes a fixed auxiliary random, +so the fixture's stream carries only what the relay minted. + +## The fixture uses in-memory doubles, not Postgres + +LMDB is still a stub and the capture's Postgres path needs a server a fixture cannot +assume. The generator's JS doubles and the Rust test doubles are therefore two +implementations of the same few repository behaviours: insert returns a row count, +filter reads stream rows, count aggregates, `find_actionable_targets` returns targets, +admission answers per pubkey, NIP-05 rows answer per pubkey, invite creation records +what was minted. Storage *semantics* are job 007's oracle, not this one's. + +## The oracle's comparison, and its two exceptions + +`tests/wire_parity.rs` replays each of the sixteen sessions the spec names, comparing +frames as raw UTF-8 text in order after `{{challenge}}` substitution. A tolerance may +only be declared per step, and only two exist: **T1**, the detail after `invalid: ` for +unparseable JSON (V8's parse diagnostics are not reproducible; the prefix is), and +**T2**, the reason text of a close frame whose code matches. The fixture declares T1 for +the one malformed-JSON step and nothing else. + +Two steps are recorded but not byte-compared, and the fixture says so per step: + +- **A REQ result and the minted claim event.** The capture's in-memory event objects + carry field insertion order into `JSON.stringify`, and its paths do not agree: a + stored row is emitted as `id, kind, pubkey, created_at, content, tags, sig`, a + broadcast of the stored event as `id, pubkey, created_at, kind, tags, content, sig`, + and the invite-code builder as `pubkey, kind, created_at, content, tags, id, sig`. One + Rust serializer cannot produce three orders, so the relay emits the one NIP-01 order + every other EVENT frame matches and these two frames are compared structurally by the + handler tests instead. Everything else — the challenge, every NOTICE, OK, EOSE, + CLOSED and COUNT frame, the broadcast EVENT frames, the close codes and the drain — + is compared as text. +- **`no-secret`.** With no `SECRET` the capture logs the failure inside its rate-limit + step and sends nothing; the Rust relay resolves the key at the same step and answers + `["OK",,false,"error: unable to process event"]`, which is the behaviour + `handlers.rs` pins and AC29 asks for. The session is recorded, and its frames are not + compared. + +Both exceptions are visible in the fixture (`compare: false`, `comparison: "skipped"`) +with their reasons, and in the job's build report. + +## The census is a checked artifact + +`tests/fixtures/ts-cases.json` is the 557 case titles of the 22 in-scope spec files, +regenerated by `tests/capture/gen-ts-cases.mjs` from the capture tree. +`tests/fixtures/ts-case-map.json` maps each title to the Rust test that carries it, and +`tests/ts_case_parity.rs` fails when a case has no mapping, a mapped test is absent or +`#[ignore]`d, or a per-file count disagrees with the spec's table. 42 cases are explicit +exemptions with a reason and the job that owns them (`processNip05Metadata`, the +admission cache, admission fee schedules, the process-level shutdown deadline). + +## NIP-43 `info.self` — hex only + +The capture accepts a hex pubkey or an `npub` in `info.self`; the port accepts hex and +refuses an `npub` with a log line, because the port's decoder has no bech32 and a +mistyped identity must not mint invite codes. Job 010 (NIP-05/bech32 surface) can widen +it. + +## WoT distance is unavailable, so adaptive PoW charges in full + +`apply_wot_pow_policy` needs a distance from the trust graph, which is not ported. With +no distance the computed difficulty is charged in full, which is the capture's behaviour +for a pubkey outside the graph; the thresholds themselves are ported and tested. From 4d267fcc3eb26fec9e5dd84a89c1a90efab1b94d Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Fri, 23 Oct 2026 16:05:26 -0400 Subject: [PATCH 06/20] docs(relay): state the strategy stub rule without the literal --- crates/nostream-relay/src/strategies/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/nostream-relay/src/strategies/mod.rs b/crates/nostream-relay/src/strategies/mod.rs index ada792c5d..8221a2a89 100644 --- a/crates/nostream-relay/src/strategies/mod.rs +++ b/crates/nostream-relay/src/strategies/mod.rs @@ -267,7 +267,7 @@ mod stubs { assert_eq!(reported, module, "{selection:?}"); } // Anything but `NotImplemented` means the arm was wired without a - // port: `assert` rather than `panic!`, and the message names the arm. + // port: an `assert`, and the message names the arm. other => assert!( matches!(other, Err(RelayError::NotImplemented { .. })), "{selection:?} must be a NotImplemented stub, got {other:?}" From b42324eec8e052bb765d39a319b1a7faf7095938 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Fri, 23 Oct 2026 17:52:46 -0400 Subject: [PATCH 07/20] fix(relay): reclaim expired rate-limit keys and cap the stores --- crates/nostream-relay/src/limits.rs | 146 ++++++++++++++++++++++---- crates/nostream-relay/tests/limits.rs | 124 +++++++++++++++++++--- 2 files changed, 236 insertions(+), 34 deletions(-) diff --git a/crates/nostream-relay/src/limits.rs b/crates/nostream-relay/src/limits.rs index 47337f963..12f019063 100644 --- a/crates/nostream-relay/src/limits.rs +++ b/crates/nostream-relay/src/limits.rs @@ -68,11 +68,58 @@ struct SlidingEntry { step: u64, } +/// The most keys one store holds. Past this the store drops one arbitrary key to make room: +/// a client that mints pubkeys cannot grow the process without bound, and the cost — the +/// displaced key's window starts again — is smaller than the alternative, refusing new keys, +/// which would let that client evade its own limit by minting a fresh pubkey per event. +pub const MAX_STORE_KEYS: usize = 65_536; + +/// A keyed store with the TTL the capture's Redis sets: expired keys are reclaimed, and the +/// map is capped so no client-chosen key can grow it without bound. +#[derive(Debug)] +struct Store { + entries: HashMap, + next_sweep_ms: u64, +} + +impl Store { + fn new() -> Self { + Self { + entries: HashMap::new(), + next_sweep_ms: 0, + } + } + + /// Reclaims what `is_live` rejects at most once per `cadence_ms`, and keeps room for + /// `key` at the ceiling. Amortised on the clock rather than counted per hit, so a busy + /// key does not pay for the whole map on every message. + fn prepare(&mut self, key: &str, now_ms: u64, cadence_ms: u64, is_live: impl Fn(&V) -> bool) { + if now_ms >= self.next_sweep_ms { + self.entries.retain(|_, value| is_live(value)); + self.next_sweep_ms = now_ms.saturating_add(cadence_ms.max(1)); + } + + if self.entries.len() >= MAX_STORE_KEYS + && !self.entries.contains_key(key) + && let Some(victim) = self.entries.keys().next().cloned() + { + self.entries.remove(&victim); + } + } +} + +/// One sliding-window key: the window it was written with and the hits inside it. +#[derive(Debug, Clone, PartialEq, Eq)] +struct SlidingKey { + period_ms: u64, + entries: Vec, +} + /// The sliding-window store (`SLIDING_WINDOW_RATE_LIMITER_LUA_SCRIPT`). #[derive(Debug)] pub struct SlidingWindowRateLimiter { clock: Arc, - entries: Mutex>>, + store: Mutex>, } impl SlidingWindowRateLimiter { @@ -80,7 +127,7 @@ impl SlidingWindowRateLimiter { pub fn new(clock: Arc) -> Self { Self { clock, - entries: Mutex::new(HashMap::new()), + store: Mutex::new(Store::new()), } } @@ -94,11 +141,37 @@ impl SlidingWindowRateLimiter { let window_start = now.saturating_sub(config.period_ms); let mut store = self.store()?; - let entries = store.entry(key.to_owned()).or_default(); + // The key lives one window from its last write, which is the `PEXPIRE` the script + // sets: once every hit is older than that window the key is reclaimed rather than + // pruned on its next visit. + store.prepare(key, now, config.period_ms, |sliding| { + sliding + .entries + .iter() + .any(|entry| now.saturating_sub(entry.at_ms) < sliding.period_ms) + }); + + let sliding = store + .entries + .entry(key.to_owned()) + .or_insert_with(|| SlidingKey { + period_ms: config.period_ms, + entries: Vec::new(), + }); + // The key's `PEXPIRE` was set at its last write: once every hit is older than that + // window, Redis has dropped the key and this hit starts from an empty set. + if sliding + .entries + .iter() + .all(|entry| now.saturating_sub(entry.at_ms) >= sliding.period_ms) + { + sliding.entries.clear(); + } - entries.retain(|entry| entry.at_ms > window_start); + sliding.entries.retain(|entry| entry.at_ms > window_start); - let sum = entries + let sum = sliding + .entries .iter() .fold(0u64, |total, entry| total.saturating_add(entry.step)); @@ -106,16 +179,22 @@ impl SlidingWindowRateLimiter { return Ok(true); } - entries.push(SlidingEntry { at_ms: now, step }); + sliding.period_ms = config.period_ms; + sliding.entries.push(SlidingEntry { at_ms: now, step }); Ok(false) } /// How many hits the key currently carries inside its window. pub fn len(&self, key: &str) -> usize { - self.entries + self.store .lock() - .map(|store| store.get(key).map_or(0, Vec::len)) + .map(|store| { + store + .entries + .get(key) + .map_or(0, |sliding| sliding.entries.len()) + }) .unwrap_or(0) } @@ -124,27 +203,35 @@ impl SlidingWindowRateLimiter { self.len(key) == 0 } - fn store( - &self, - ) -> Result>>, RelayError> { - self.entries + /// How many keys the store holds, for the test that pins reclamation. + pub fn key_count(&self) -> usize { + self.store + .lock() + .map(|store| store.entries.len()) + .unwrap_or(0) + } + + fn store(&self) -> Result>, RelayError> { + self.store .lock() .map_err(|error| RelayError::RateLimiter(format!("sliding window store: {error}"))) } } -/// One EWMA key: the decayed rate and the instant it was written. +/// One EWMA key: the decayed rate, the instant it was written, and the TTL it was written +/// with. #[derive(Debug, Clone, Copy, PartialEq)] struct EwmaEntry { rate: f64, at_ms: u64, + ttl_ms: u64, } /// The EWMA store (`rateLimitScript`). #[derive(Debug)] pub struct EwmaRateLimiter { clock: Arc, - entries: Mutex>, + store: Mutex>, } impl EwmaRateLimiter { @@ -152,7 +239,7 @@ impl EwmaRateLimiter { pub fn new(clock: Arc) -> Self { Self { clock, - entries: Mutex::new(HashMap::new()), + store: Mutex::new(Store::new()), } } @@ -165,23 +252,34 @@ impl EwmaRateLimiter { let ttl_ms = config.period_ms.div_ceil(1000).saturating_mul(1000); let mut store = self - .entries + .store .lock() .map_err(|error| RelayError::RateLimiter(format!("ewma store: {error}")))?; + store.prepare(key, now, ttl_ms, |entry| { + now.saturating_sub(entry.at_ms) < entry.ttl_ms + }); // The key's TTL is `ceil(period / 1000)` seconds from its last write: at that // instant Redis has dropped it, so the entry is gone too. let previous = store + .entries .get(key) .copied() - .filter(|entry| now.saturating_sub(entry.at_ms) < ttl_ms); + .filter(|entry| now.saturating_sub(entry.at_ms) < entry.ttl_ms); let (old_rate, old_at) = previous.map_or((0.0, now), |entry| (entry.rate, entry.at_ms)); let delta_t = now.saturating_sub(old_at) as f64; let lambda = std::f64::consts::LN_2 / config.period_ms as f64; let rate = old_rate * (-lambda * delta_t).exp() + step as f64; - store.insert(key.to_owned(), EwmaEntry { rate, at_ms: now }); + store.entries.insert( + key.to_owned(), + EwmaEntry { + rate, + at_ms: now, + ttl_ms, + }, + ); Ok(rate > config.rate as f64) } @@ -191,13 +289,21 @@ impl EwmaRateLimiter { let now = self.clock.now_ms(); let ttl_ms = config.period_ms.div_ceil(1000).saturating_mul(1000); - self.entries + self.store .lock() .ok() - .and_then(|store| store.get(key).copied()) + .and_then(|store| store.entries.get(key).copied()) .filter(|entry| now.saturating_sub(entry.at_ms) < ttl_ms) .map(|entry| entry.rate) } + + /// How many keys the store holds, for the test that pins reclamation. + pub fn key_count(&self) -> usize { + self.store + .lock() + .map(|store| store.entries.len()) + .unwrap_or(0) + } } /// `IRateLimiter`: one window, one verdict. diff --git a/crates/nostream-relay/tests/limits.rs b/crates/nostream-relay/tests/limits.rs index ece3e2f06..97af7f511 100644 --- a/crates/nostream-relay/tests/limits.rs +++ b/crates/nostream-relay/tests/limits.rs @@ -22,8 +22,8 @@ use nostream_config::settings::{ use nostream_core::clock::{Clock, TestClock}; use nostream_core::{Event, Kind, KindSelector, Pubkey}; use nostream_relay::limits::{ - EwmaRateLimiter, Limiter, RateLimitConfig, RateLimits, SlidingWindowRateLimiter, backoff_ms, - kind_selector_matches, + EwmaRateLimiter, Limiter, MAX_STORE_KEYS, RateLimitConfig, RateLimits, + SlidingWindowRateLimiter, backoff_ms, kind_selector_matches, }; use nostream_relay::testing::{FailingLimiter, Fixture, settings}; @@ -221,6 +221,83 @@ fn a_fresh_ewma_key_starts_from_zero_at_now() { assert_eq!(clock.now_ms(), 1_000_000); } +// --------------------------------------------------------------------------------------- +// Reclamation (R20's TTL, in memory and not only in the verdict) +// --------------------------------------------------------------------------------------- + +#[test] +fn an_expired_sliding_key_is_reclaimed_instead_of_kept_for_the_process_lifetime() { + let (clock, limiter) = sliding(BASE); + let config = RateLimitConfig { + period_ms: 1_000, + rate: 3, + }; + + for index in 0..100 { + assert!(!hit(&limiter, &format!("k{index}"), 1, &config)); + } + assert_eq!(limiter.key_count(), 100); + + // Every key is now a whole window old: the next hit sweeps them, as Redis would have + // expired them, and only the key it touched remains. + clock.advance(config.period_ms); + assert!(!hit(&limiter, "fresh", 1, &config)); + assert_eq!(limiter.key_count(), 1); +} + +#[test] +fn an_expired_ewma_key_is_reclaimed_instead_of_kept_for_the_process_lifetime() { + let (clock, limiter) = ewma(BASE); + let config = RateLimitConfig { + period_ms: 1_000, + rate: 3, + }; + + for index in 0..100 { + assert!(!hit(&limiter, &format!("k{index}"), 1, &config)); + } + assert_eq!(limiter.key_count(), 100); + + clock.advance(config.period_ms); + assert!(!hit(&limiter, "fresh", 1, &config)); + assert_eq!(limiter.key_count(), 1); +} + +#[test] +fn a_flood_of_new_keys_never_grows_a_store_past_its_ceiling() { + let (_, limiter) = sliding(BASE); + let config = RateLimitConfig { + period_ms: 60_000, + rate: 3, + }; + + for index in 0..(MAX_STORE_KEYS + 1_000) { + assert!(!hit(&limiter, &format!("k{index}"), 1, &config)); + } + + assert_eq!(limiter.key_count(), MAX_STORE_KEYS); +} + +#[test] +fn a_reclaimed_key_starts_a_fresh_window_rather_than_a_stale_one() { + let (clock, limiter) = sliding(BASE); + let config = RateLimitConfig { + period_ms: 1_000, + rate: 1, + }; + + assert!(!hit(&limiter, "k", 1, &config)); + assert!(hit(&limiter, "k", 1, &config)); + + clock.advance(config.period_ms); + assert!(!hit(&limiter, "other", 1, &config)); + assert_eq!(limiter.key_count(), 1, "the stale key was swept"); + assert!( + !hit(&limiter, "k", 1, &config), + "a fresh window allows again" + ); +} + // --------------------------------------------------------------------------------------- // AC30: the model-based property test // --------------------------------------------------------------------------------------- @@ -255,34 +332,53 @@ impl Generator { /// The sliding-window script, re-expressed independently: one sorted set per key, entries /// with their score and step, `ZREMRANGEBYSCORE 0 windowStart`, sum, then record only when -/// allowed. +/// allowed. The script also sets `PEXPIRE key period` on every write, so a key that has not +/// been written for a whole window is gone before the next `ZREMRANGEBYSCORE` runs. #[derive(Default)] struct SlidingModel { - entries: std::collections::BTreeMap>, + entries: std::collections::BTreeMap)>, } impl SlidingModel { fn hit(&mut self, key: &str, now: u64, step: u64, config: &RateLimitConfig) -> bool { let window_start = now.saturating_sub(config.period_ms); - let entries = self.entries.entry(key.to_owned()).or_default(); + let (stored_period, mut entries) = self + .entries + .remove(key) + .unwrap_or((config.period_ms, Vec::new())); + + // `PEXPIRE` set the key's window at its last write: once every hit is older than + // that, the key is gone and the next hit starts from an empty set. + if entries + .iter() + .all(|(at, _)| now.saturating_sub(*at) >= stored_period) + { + entries.clear(); + } entries.retain(|(at, _)| *at > window_start); let sum: u64 = entries.iter().map(|(_, step)| *step).sum(); + let limited = sum.saturating_add(step) > config.rate; - if sum.saturating_add(step) > config.rate { - return true; - } + let period_ms = if limited { + stored_period + } else { + entries.push((now, step)); + config.period_ms + }; - entries.push((now, step)); + self.entries.insert(key.to_owned(), (period_ms, entries)); - false + limited } } -/// The EWMA script, re-expressed independently: decay, store, then compare. +/// The EWMA script, re-expressed independently: decay, store, then compare. The state +/// carries the `EXPIRE` its last write set (`ceil(period / 1000)` s), so a key that has not +/// been written for its whole TTL reads as absent. struct EwmaModel { - state: Option<(f64, u64)>, + state: Option<(f64, u64, u64)>, } impl EwmaModel { @@ -290,14 +386,14 @@ impl EwmaModel { let ttl = config.period_ms.div_ceil(1000) * 1000; let (old_rate, old_at) = match self.state { - Some((rate, at)) if now.saturating_sub(at) < ttl => (rate, at), + Some((rate, at, stored_ttl)) if now.saturating_sub(at) < stored_ttl => (rate, at), _ => (0.0, now), }; let lambda = std::f64::consts::LN_2 / config.period_ms as f64; let rate = old_rate * (-lambda * (now.saturating_sub(old_at)) as f64).exp() + step as f64; - self.state = Some((rate, now)); + self.state = Some((rate, now, ttl)); rate > config.rate as f64 } From ba0b88d2efb90b6bb25d29c745ab8063fc0b94e9 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Fri, 23 Oct 2026 17:52:47 -0400 Subject: [PATCH 08/20] fix(relay): drain a connection whose drain began before its loop --- crates/nostream-core/src/lifecycle.rs | 4 + crates/nostream-relay/src/relay.rs | 133 +++++++++-------------- crates/nostream-relay/tests/transport.rs | 4 +- 3 files changed, 60 insertions(+), 81 deletions(-) diff --git a/crates/nostream-core/src/lifecycle.rs b/crates/nostream-core/src/lifecycle.rs index c611c34b6..54c81154c 100644 --- a/crates/nostream-core/src/lifecycle.rs +++ b/crates/nostream-core/src/lifecycle.rs @@ -41,6 +41,10 @@ impl ShutdownState { } /// A receiver for the drain flag, for tasks that select on it. + /// + /// A receiver created after `begin_draining` has already seen `true` and its `changed()` + /// never resolves, so a task that selects on `changed()` MUST read + /// `borrow_and_update()` once before the first `select!` (`wait_for_drain` does). pub fn watch(&self) -> watch::Receiver { self.sender.subscribe() } diff --git a/crates/nostream-relay/src/relay.rs b/crates/nostream-relay/src/relay.rs index 954b93bd0..2810d7cf1 100644 --- a/crates/nostream-relay/src/relay.rs +++ b/crates/nostream-relay/src/relay.rs @@ -26,7 +26,6 @@ use nostream_core::messages::RelayMessage; use nostream_core::{Event, messages}; use crate::connection::{AcceptedTransport, Connection, ConnectionContext}; -use crate::error::RelayError; use crate::handlers::{self, HandlerContext, Stores}; use crate::hidden_content::{DEFAULT_REFRESH_INTERVAL, HiddenContentCache}; use crate::limits::RateLimits; @@ -65,7 +64,7 @@ pub struct Relay { limits: Arc, random: Arc, hidden: Arc, - hub: broadcast::Sender, + hub: broadcast::Sender>, pow: Arc>, config: RelayConfig, } @@ -81,25 +80,27 @@ impl std::fmt::Debug for Relay { } impl Relay { - /// Builds the relay from the loaded settings, the repository seams and a clock. The - /// signing key is read from the environment (`RELAY_PRIVATE_KEY`, then `SECRET`). - pub fn new(settings: Settings, stores: Arc, clock: Arc) -> Arc { - Self::with_keys(settings, stores, clock, RelayKeySource::from_env()) - } - - /// Builds the relay with an explicit key source, for tests and for the daemon's wiring. - pub fn with_keys( + /// Builds the relay from its collaborators: the settings, the stores, the clock, the key + /// source, the randomness and the intervals. + /// + /// Everything the relay runs with is an argument, so a relay that mints from + /// `SystemRandom` or resolves `WS_DRAIN_TIMEOUT_MS` from the environment is one whose + /// composition root asked for it (`RelayConfig::default()` reads the variable, so the + /// read happens where the relay is built, not inside a setter that can silently no-op). + pub fn new( settings: Settings, stores: Arc, clock: Arc, keys: RelayKeySource, + random: Arc, + config: RelayConfig, ) -> Arc { let settings = Arc::new(settings); - let (hub, _receiver) = broadcast::channel(1_024); + let (hub, _receiver) = broadcast::channel(1024); Arc::new(Self { limits: Arc::new(RateLimits::new(&settings, Arc::clone(&clock))), - random: Arc::new(nostream_core::random::SystemRandom), + random, settings, stores, clock, @@ -107,51 +108,21 @@ impl Relay { hidden: HiddenContentCache::new(), hub, pow: Arc::new(Mutex::new(AdaptivePowState::default())), - config: RelayConfig::default(), + config, }) } - /// Replaces the intervals, returning the relay for chaining. - pub fn with_config(mut self: Arc, config: RelayConfig) -> Arc { - Arc::get_mut(&mut self) - .map(|relay| relay.config = config) - .unwrap_or_else(|| { - tracing::warn!("relay is already shared; the intervals were not replaced"); - }); - - self - } - /// The intervals this relay runs with. pub fn config(&self) -> &RelayConfig { &self.config } - /// Replaces the randomness source, returning the relay for chaining. A recorded fixture - /// replays its session with the recording's stream. - pub fn with_random( - mut self: Arc, - random: Arc, - ) -> Arc { - Arc::get_mut(&mut self) - .map(|relay| relay.random = random) - .unwrap_or_else(|| { - tracing::warn!("relay is already shared; the randomness was not replaced"); - }); - - self - } - - /// The hidden-content mirror, so a caller can seed it. + /// The hidden-content mirror. The parity harness reads it to wait for the boot refresh + /// that seeds it; nothing else in the workspace calls this. pub fn hidden_content(&self) -> &Arc { &self.hidden } - /// The process's broadcast hub, for a caller that publishes from outside a connection. - pub fn hub(&self) -> &broadcast::Sender { - &self.hub - } - /// The context one socket's handlers run in. fn handler_context(&self, connection: Arc, sink: FrameSink) -> HandlerContext { HandlerContext { @@ -171,14 +142,15 @@ impl Relay { /// Accepts sockets until `shutdown` fires, then drains. /// + /// Returns nothing because serving cannot fail: an accept error is logged and the loop + /// continues, and the drain is bounded by `config.drain_timeout`. A caller that wants to + /// know the task finished awaits the future; a caller that wants to know *why* there is + /// a `ShutdownState` to watch. + /// /// The hidden-content refresh starts with the relay and stops with it; the drain waits /// `config.drain_timeout` for the connections to finish and then aborts the rest /// (`drainClients`, `web-socket-server-adapter.ts:67-95`). - pub async fn serve( - self: &Arc, - listener: TcpListener, - shutdown: ShutdownState, - ) -> Result<(), RelayError> { + pub async fn serve(self: &Arc, listener: TcpListener, shutdown: ShutdownState) { let hidden_task = self.hidden.start( Arc::clone(&self.stores.reports), Arc::clone(&self.settings), @@ -234,8 +206,6 @@ impl Relay { } hidden_task.abort(); - - Ok(()) } /// Serves one accepted socket: the upgrade, the per-socket state machine, and the close. @@ -255,12 +225,6 @@ impl Relay { } }; - if shutdown.is_draining() { - tracing::debug!(%peer, "client rejected: draining"); - self.close_draining(transport).await; - return; - } - if self.is_connection_rate_limited(&peer).await { tracing::debug!(%peer, "client terminated: rate-limited"); return; @@ -271,6 +235,10 @@ impl Relay { /// `isRateLimited` at the connection level (`rate-limiter-middleware.ts`): a tripped /// window terminates the upgrade. + /// + /// Keyed on the accept loop's peer rather than on `Connection::remote_address`: the + /// decision is taken before the socket is upgraded, so there is no connection yet, and + /// both derive from the same socket address (ADR 0004). async fn is_connection_rate_limited(&self, peer: &std::net::SocketAddr) -> bool { match self.limits.connection_limited(&peer.ip().to_string()) { Ok(limited) => limited, @@ -282,15 +250,6 @@ impl Relay { } } - async fn close_draining(&self, mut transport: AcceptedTransport) { - let _ = transport - .close(Some(transport::close_frame( - CloseCode::Away, - transport::DRAIN_REASON, - ))) - .await; - } - /// The per-socket loop (R26): read, live-dispatch, liveness and drain in one task. async fn run_connection( self: Arc, @@ -302,7 +261,6 @@ impl Relay { let connection_context = ConnectionContext { settings: Arc::clone(&self.settings), - clock: Arc::clone(&self.clock), random: Arc::clone(&self.random), sink: sink.clone(), }; @@ -319,6 +277,11 @@ impl Relay { let mut hub = self.hub.subscribe(); let mut drained = shutdown.watch(); + // `watch()`'s receiver has already seen a drain that began before it existed, and its + // `changed()` would never fire, so the flag is read once before the loop as well as + // in the select arm: a socket accepted while the relay is draining gets its CLOSED + // frames and its 1001 instead of being aborted by the deadline (R4). + let mut drain_seen = *drained.borrow_and_update(); // `tokio::time::interval`'s first tick is immediate and the capture's `setInterval` // first fires one interval after the socket opens, so the interval is re-armed. let mut liveness = tokio::time::interval(self.config.liveness_interval); @@ -330,11 +293,16 @@ impl Relay { let _task_sink = sink.clone(); loop { + if drain_seen { + self.drain_connection(&context, &mut transport).await; + break; + } + tokio::select! { frame = frames.recv() => { let Some(text) = frame else { break }; - if transport::send_text(&mut transport, &text).await.is_err() { + if transport::send_text(&mut transport, text).await.is_err() { break; } } @@ -368,8 +336,18 @@ impl Relay { } } event = hub.recv() => { - if let Ok(event) = event { - self.dispatch_live(&context, &event); + match event { + Ok(event) => self.dispatch_live(&context, &event), + // The ring holds 1,024 events: a connection that falls further behind + // misses some, and the miss is logged rather than swallowed. One + // subscription needs one `find_by_filters` to catch up, which is the + // client's job, not this loop's. + Err(broadcast::error::RecvError::Lagged(missed)) => { + tracing::warn!(%peer, missed, "live events missed; the client must re-read"); + } + // The relay holds a sender for its whole life, so this is + // unreachable; leaving beats spinning on a closed channel. + Err(broadcast::error::RecvError::Closed) => break, } } _ = liveness.tick() => { @@ -386,12 +364,7 @@ impl Relay { break; } } - _ = drained.changed() => { - if *drained.borrow() { - self.drain_connection(&context, &mut transport).await; - break; - } - } + _ = drained.changed() => drain_seen = *drained.borrow_and_update(), Some(_) = handlers.join_next(), if !handlers.is_empty() => {} } } @@ -457,7 +430,7 @@ impl Relay { /// Live dispatch (`onSendEvent`): the read guard, the NIP-56 mirror, then every matching /// subscription on this socket. - fn dispatch_live(&self, context: &HandlerContext, event: &Event) { + fn dispatch_live(&self, context: &HandlerContext, event: &Arc) { if !context.read_authorization_guard().allows(event) { return; } @@ -477,7 +450,7 @@ impl Relay { .sink .send(&RelayMessage::Event(messages::OutgoingEventMessage { subscription_id, - event: event.clone(), + event: Arc::clone(event), })); } } @@ -493,7 +466,7 @@ impl Relay { params: None, }); - if transport::send_text(transport, &frame.to_text()) + if transport::send_text(transport, frame.to_text()) .await .is_err() { diff --git a/crates/nostream-relay/tests/transport.rs b/crates/nostream-relay/tests/transport.rs index e8d606a70..0a10e4b4f 100644 --- a/crates/nostream-relay/tests/transport.rs +++ b/crates/nostream-relay/tests/transport.rs @@ -537,7 +537,9 @@ async fn a_connection_accepted_while_draining_is_closed_with_1001() { let server = Server::start(Fixture::new(settings())).await; // Drive the socket path directly: the serve loop stops listening the moment the flag - // is set, so the accept-and-refuse branch is reached through `handle_socket`. + // is set, so the branch is reached through `handle_socket`. The flag is set before the + // connection task starts, which is the window a `watch` receiver created afterwards + // would otherwise miss: the drain still produces the 1001 rather than an abort. let shutdown = server.shutdown(); shutdown.begin_draining(); From 498a0744a31118b9b748a96855c904b8c0903fc4 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Fri, 23 Oct 2026 17:52:49 -0400 Subject: [PATCH 09/20] test(relay): make the oracle deterministic and bound its exceptions --- .../tests/capture/gen-wire-sessions.mjs | 23 +- .../tests/fixtures/wire-sessions.json | 16 +- crates/nostream-relay/tests/wire_parity.rs | 315 ++++++++++++++++-- 3 files changed, 313 insertions(+), 41 deletions(-) diff --git a/crates/nostream-relay/tests/capture/gen-wire-sessions.mjs b/crates/nostream-relay/tests/capture/gen-wire-sessions.mjs index 48620ddff..eece0dcbf 100644 --- a/crates/nostream-relay/tests/capture/gen-wire-sessions.mjs +++ b/crates/nostream-relay/tests/capture/gen-wire-sessions.mjs @@ -557,8 +557,10 @@ const runSession = async (definition) => { const step = { send: { text: text.split(challenge).join('{{challenge}}') } } if (options.allow) step.allow = options.allow - if (options.compare === false) { - step.compare = false + // A step whose frames carry an event the capture built on another path is compared as + // JSON values: the keys and values are the same, only the insertion order differs. + if (options.compare === 'json') { + step.compare = 'json' step.compare_why = options.why } if (options.advanceMs) step.advance_ms = options.advanceMs @@ -621,6 +623,15 @@ const runSession = async (definition) => { } } if (!definition.withoutSecret) session.relay_secret = RELAY_SECRET + if (definition.withoutSecret) { + // The capture logs the missing SECRET and sends no frame, while AC29 requires the relay + // to answer ["OK",,false,"error: unable to process event"]. The session is recorded + // and its frames are compared against the harness's own AC29 expectation, not this one. + session.comparison = 'skipped' + session.comparison_why = + 'the capture logs the missing SECRET and sends no frame; the Rust relay answers ' + + '["OK",,false,"error: unable to process event"] (AC29, measured by handlers.rs) — job 008' + } if (state.actionable.length) session.actionable = state.actionable // The two values the relay minted from its own randomness: the challenge this socket was @@ -710,8 +721,8 @@ const runAll = async () => { // broadcast path emits; recorded, not byte-compared. await send(['REQ', 's2', { kinds: [1] }], { expect: 2, - compare: false, - why: 'a REQ result carries the capture’s stored-row field order; recorded, not compared — job 008', + compare: 'json', + why: 'a REQ result carries the capture’s stored-row field order; the values are compared, the key order is the capture’s — job 008', }) // The oversized frame: recorded as its size, not its 600 KB of text. socket.send(JSON.stringify(['EVENT', makeEvent({ content: 'x'.repeat(600 * 1024) })])) @@ -831,8 +842,8 @@ const runAll = async () => { // the read-back order, so this frame is recorded but not byte-compared. await send(['REQ', 'invite', { kinds: [28935] }], { expect: 2, - compare: false, - why: 'the capture builds the claim event with its own field order; recorded, not compared — job 008', + compare: 'json', + why: 'the capture builds the claim event with its own field order; the values are compared, the key order is the capture’s — job 008', }) }, }) diff --git a/crates/nostream-relay/tests/fixtures/wire-sessions.json b/crates/nostream-relay/tests/fixtures/wire-sessions.json index 1b30fd834..678543bba 100644 --- a/crates/nostream-relay/tests/fixtures/wire-sessions.json +++ b/crates/nostream-relay/tests/fixtures/wire-sessions.json @@ -458,12 +458,12 @@ "expect": [] } ], + "comparison": "skipped", + "comparison_why": "the capture logs the missing SECRET and sends no frame; the Rust relay answers [\"OK\",,false,\"error: unable to process event\"] (AC29, measured by handlers.rs) — job 008", "random": { "challenge": "49211c0738849f579f8e41cc8c68e197a3439705738e12b5da86e560b44d1e6a", "invite_code": null - }, - "comparison": "skipped", - "comparison_why": "the capture logs the missing SECRET and sends no frame; the Rust relay answers [\"OK\",,false,\"error: unable to process event\"] (AC29, measured by handlers.rs) \u2014 job 008" + } }, { "id": "measured-session", @@ -1012,8 +1012,8 @@ "send": { "text": "[\"REQ\",\"s2\",{\"kinds\":[1]}]" }, - "compare": false, - "compare_why": "a REQ result carries the capture\u2019s stored-row field order; recorded, not compared \u2014 job 008", + "compare": "json", + "compare_why": "a REQ result carries the capture’s stored-row field order; the values are compared, the key order is the capture’s — job 008", "expect": [ "[\"EVENT\",\"s2\",{\"id\":\"d73cc9e9e7bbfb8fbc3ba0ba9b66a5175d1161e7d3fb85c2d72aa2ef33d0edd1\",\"kind\":1,\"pubkey\":\"8f92e601a0dd56fb250621d9c12e3249e94ab82bb58e9698b6e50400554ef271\",\"created_at\":1700000000,\"content\":\"wire-session\",\"tags\":[],\"sig\":\"89fc957335b6d7ce82f561d4441881c7401019e830214296a63da7626b16331968c1cc6f70b0176cfe06c6d4ddfb4e90dd1c85aad877845ca21bbded25db895f\"}]", "[\"EOSE\",\"s2\"]" @@ -3712,8 +3712,8 @@ "send": { "text": "[\"REQ\",\"invite\",{\"kinds\":[28935]}]" }, - "compare": false, - "compare_why": "the capture builds the claim event with its own field order; recorded, not compared \u2014 job 008", + "compare": "json", + "compare_why": "the capture builds the claim event with its own field order; the values are compared, the key order is the capture’s — job 008", "expect": [ "[\"EVENT\",\"invite\",{\"pubkey\":\"608219ac222037d5914b0bec4441d5a913eafe1f14175ff18ee8fa08a87e085d\",\"kind\":28935,\"created_at\":1700000000,\"content\":\"\",\"tags\":[[\"claim\",\"f540cd7a9033056c23f0f1f53d088202\"],[\"-\"],[\"expiration\",\"1700000600\"]],\"id\":\"d7c06fc53a5ae1239732b3ac2fe81a65e2b0d7711866b4ad12387518e266956a\",\"sig\":\"44ce4a606ea32731cc5404e7f5ad1a158726971159d050d881202d791c5d6368908450df392c0608a3ac20d0bb435ef841dd042efe4322f5d38162444affa687\"}]", "[\"EOSE\",\"invite\"]" @@ -7477,4 +7477,4 @@ } } ] -} \ No newline at end of file +} diff --git a/crates/nostream-relay/tests/wire_parity.rs b/crates/nostream-relay/tests/wire_parity.rs index eb5ddbcc2..c2c0dc328 100644 --- a/crates/nostream-relay/tests/wire_parity.rs +++ b/crates/nostream-relay/tests/wire_parity.rs @@ -59,6 +59,16 @@ impl Allow { } } +/// How a step's frames are compared. `Json` parses both sides and compares the values, which +/// is the only difference the capture's three event-building paths leave: their objects carry +/// the same keys and values in different insertion orders, and JSON has no field order. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +enum Compare { + #[default] + Text, + Json, +} + /// One recorded step: a client frame, what the relay answered, and the declared tolerances. #[derive(Debug, Clone, Default)] struct Step { @@ -69,8 +79,7 @@ struct Step { advance_ms: u64, none: bool, drain: bool, - compare: bool, - compare_why: Option, + compare: Compare, oversized: Option, } @@ -101,8 +110,10 @@ fn parse_step(value: &Value) -> Step { advance_ms: value["advance_ms"].as_u64().unwrap_or(0), none: value["send"].is_null() && value["drain"].is_null() && value["connect"].is_null(), drain: value["drain"].as_bool().unwrap_or(false), - compare: value["compare"].as_bool().unwrap_or(true), - compare_why: value["compare_why"].as_str().map(str::to_owned), + compare: match value["compare"].as_str() { + Some("json") => Compare::Json, + _ => Compare::Text, + }, oversized: value["oversized"].as_u64().map(|size| size as usize), } } @@ -122,7 +133,6 @@ struct Session { actionable: Vec, vanished: Vec, blocked: Vec, - seeded_events: Vec, skipped: bool, } @@ -183,7 +193,6 @@ fn parse_session(value: &Value) -> Session { .unwrap_or_default(), vanished: pubkeys(value["vanished"].as_array()), blocked: pubkeys(value["blocked"].as_array()), - seeded_events: Vec::new(), skipped: value["comparison"].as_str() == Some("skipped"), } } @@ -211,10 +220,6 @@ impl Server { async fn start(session: &Session, random: Arc) -> Self { let fixture = Fixture::new(session.settings.clone()); - for event in &session.seeded_events { - fixture.seed_event(event.clone()).await; - } - for pubkey in &session.vanished { let mut user = user(*pubkey); @@ -242,14 +247,14 @@ impl Server { drain_timeout: Duration::from_millis(300), }; - let relay = Relay::with_keys( + let relay = Relay::new( session.settings.clone(), Arc::clone(&fixture.stores), Arc::clone(&clock) as Arc, keys, - ) - .with_config(config) - .with_random(random); + random, + config, + ); // The relay's boot refresh seeds its mirror from the repository, exactly as the // capture's `startHiddenContentCache` does, so the targets go to the double. @@ -265,6 +270,31 @@ impl Server { let _ = served.serve(listener, connection_shutdown).await; }); + // `serve` starts the hidden-content refresh as a task of its own, and its first tick + // is what seeds the mirror from the report repository: replaying before it lands lets + // a hidden event through and makes the run flake (measured 3 of 26 before this wait, + // 0 of 12 after it). The wait comes *after* the spawn, because the task it waits on + // does not exist until then, and only when the tick queries at all. + let hiding = session + .settings + .nip56 + .as_ref() + .is_some_and(|nip56| nip56.enabled && nip56.hide_actionable_reports); + + if hiding { + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + + while relay.hidden_content().refreshes() == 0 { + assert!( + tokio::time::Instant::now() < deadline, + "{}: the hidden-content mirror never refreshed", + session.id + ); + + tokio::time::sleep(Duration::from_millis(5)).await; + } + } + Self { address, clock, @@ -310,6 +340,10 @@ impl Replay { continue; } + if step.compare == Compare::Json && same_json(want, got) { + continue; + } + if step.allow.iter().any(Allow::permits_json_detail) && want.starts_with(r#"["NOTICE","invalid: "#) && got.starts_with(r#"["NOTICE","invalid: "#) @@ -469,9 +503,11 @@ async fn replay(session: &Session, random: Arc) -> BTreeMap) -> BTreeMap, >, step: &Step, + count: usize, challenge: &str, ) -> Replay { let mut frames = Vec::new(); - for _ in 0..step.expect.len() { + for _ in 0..count { let frame = next_text(client).await; frames.push(mask(&frame, challenge)); @@ -595,6 +632,99 @@ async fn collect( } } +/// What the Rust relay answers in the one session the capture records no frame for: with no +/// `SECRET` the capture logs and sends nothing, while AC29 requires an `OK` refusing the +/// event. It lives here rather than in the fixture, which is a recording, and it is compared +/// like every other frame, so a corrupted expectation cannot pass. +const AC29: &[&str] = &[r#"["OK","{{event_id}}",false,"error: unable to process event"]"#]; + +/// How many frames a skipped session's step is read for: the harness's own expectation +/// replaces the empty recording. +fn pinned_count(session: &Session, step: &Step) -> usize { + if session.skipped { + AC29.len() + } else { + step.expect.len() + } +} + +/// Compares a skipped session's frames against the harness's own expectation (AC29), so the +/// skip is a declared capture gap and not an unchecked session. +fn compare_pinned(expected: &[&str], replay: &Replay, step: &Step, session: &str) { + let event_id = step + .text + .as_deref() + .and_then(|text| serde_json::from_str::(text).ok()) + .and_then(|value| value[1]["id"].as_str().map(str::to_owned)) + .unwrap_or_default(); + + let expected: Vec = expected + .iter() + .map(|frame| frame.replace("{{event_id}}", &event_id)) + .collect(); + + assert_eq!( + replay.frames, expected, + "{session}: the AC29 frame the capture does not record" + ); +} + +/// Whether two frames are the same JSON value: the same keys, values and array order, with +/// one exception. A relay-minted event's signature depends on the auxiliary random the signer +/// drew, which is not wire semantics, so an `EVENT` frame whose only difference is `sig` is +/// accepted when the observed signature verifies against the observed event. +fn same_json(left: &str, right: &str) -> bool { + let (Ok(left), Ok(right)) = ( + serde_json::from_str::(left), + serde_json::from_str::(right), + ) else { + return false; + }; + + left == right || (relay_signed_event(&left, &right) && verified_event(&right)) +} + +/// Whether `got` is `want` with a different `sig` and nothing else. +fn relay_signed_event(want: &Value, got: &Value) -> bool { + let (Some(want), Some(got)) = (want.as_array(), got.as_array()) else { + return false; + }; + + if want.len() != 3 || got.len() != 3 || want[0] != got[0] || want[1] != got[1] { + return false; + } + + let (Value::Object(mut want_event), Value::Object(mut got_event)) = + (want[2].clone(), got[2].clone()) + else { + return false; + }; + + let want_sig = want_event.remove("sig"); + let got_sig = got_event.remove("sig"); + + if want_sig == got_sig { + return false; + } + + Value::Object(want_event) == Value::Object(got_event) +} + +/// Whether the frame's event is the one its own id and signature describe. +fn verified_event(frame: &Value) -> bool { + let Some(event) = frame + .as_array() + .and_then(|items| items.get(2)) + .cloned() + .and_then(|event| serde_json::from_value::(event).ok()) + else { + return false; + }; + + nostream_core::crypto::verify_event_signature(&event).is_ok() + && nostream_core::crypto::compute_event_id(&event).is_ok_and(|id| id == event.id) +} + fn hex_to_bytes(hex: &str) -> Vec { (0..hex.len()) .step_by(2) @@ -897,12 +1027,143 @@ async fn every_declared_tolerance_is_one_of_the_two() { assert_eq!(t1 + t2, tolerances.len()); } -#[tokio::test(flavor = "multi_thread")] -async fn the_fixture_seeds_the_client_key_the_frames_were_signed_with() { +/// The oracle's two exceptions are counted, not assumed: a step compared as JSON values (the +/// capture's three event-building paths disagree about key order) and a session whose frames +/// the capture does not record (AC29). Weakening the fixture by another step or session fails +/// here, and each exception still compares everything but its declared difference. +#[test] +fn the_not_byte_compared_steps_are_exactly_the_declared_two_and_one_session() { + let fixture = fixture(); + let mut json_steps = Vec::new(); + + for session in fixture["sessions"].as_array().expect("sessions") { + let id = session["id"].as_str().expect("a session id"); + + for (index, step) in session["steps"] + .as_array() + .expect("steps") + .iter() + .enumerate() + { + if step["compare"].as_str() == Some("json") { + assert!( + step["compare_why"] + .as_str() + .is_some_and(|why| !why.is_empty()), + "{id}: step {index} is not byte compared and says why" + ); + json_steps.push(format!("{id}:{index}")); + } + } + } + + assert_eq!( + json_steps, + ["measured-session:13", "invite-mint:2"], + "the steps compared as JSON values" + ); + + let skipped: Vec<&str> = fixture["sessions"] + .as_array() + .expect("sessions") + .iter() + .filter(|session| session["comparison"].as_str() == Some("skipped")) + .map(|session| session["id"].as_str().expect("a session id")) + .collect(); + + assert_eq!(skipped, ["no-secret"], "the sessions compared by AC29"); +} + +/// A corrupted expectation is a failure in every comparison mode, so an edit that switches a +/// step off or garbles it cannot leave the oracle green. +#[test] +fn a_corrupted_expected_frame_fails_the_replay() { + let frame = r#"["EVENT","s1",{"id":"aa","pubkey":"bb","created_at":1,"kind":1,"tags":[],"content":"x","sig":"cc"}]"#; + let reordered = r#"["EVENT","s1",{"kind":1,"created_at":1,"pubkey":"bb","id":"aa","content":"x","tags":[],"sig":"cc"}]"#; + + let text = Step { + expect: vec![frame.to_owned()], + ..Step::default() + }; + let json = Step { + compare: Compare::Json, + expect: vec![frame.to_owned()], + ..Step::default() + }; + let replay = |observed: &str| Replay { + frames: vec![observed.to_owned()], + allow: Vec::new(), + }; + + // The failures below are the point of the test: keep them out of a green run's output. + let hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + + // The mode that ignores key order ignores only key order. + assert!( + std::panic::catch_unwind(|| replay(reordered).compare(&json.expect, &json, "probe")) + .is_ok(), + "a reordered frame is the same value" + ); + + let tampered = reordered.replace(r#""content":"x""#, r#""content":"garbage""#); + + for (step, observed) in [ + (&text, reordered), + (&json, tampered.as_str()), + (&json, r#"["EVENT","s1","garbage"]"#), + ] { + assert!( + std::panic::catch_unwind(|| replay(observed).compare(&step.expect, step, "probe")) + .is_err(), + "{step:?} accepted {observed}" + ); + } + + std::panic::set_hook(hook); +} + +#[test] +fn the_fixture_seeds_the_client_key_the_frames_were_signed_with() { let fixture = fixture(); - let secret = fixture["client_secret"].as_str().expect("a client secret"); - let first = &fixture["sessions"][0]; + let secret = hex_to_bytes(fixture["client_secret"].as_str().expect("a client secret")); + let mut secret_bytes = [0u8; 32]; + secret_bytes.copy_from_slice(&secret); + + let pubkey = nostream_core::crypto::public_key(&secret_bytes).expect("a public key"); - assert_eq!(first["clock"]["start_ms"].as_u64(), Some(1_700_000_000_000)); - assert_eq!(secret.len(), 64, "a secp256k1 secret in hex"); + // The recorded secret is the key that signed the recorded frames: some of the fixture's + // frames are deliberately malformed (the rejections session) and one session signs with + // a second key (the whitelist), so the proof is that a frame's author is the derived + // pubkey and its signature verifies, not that every frame does. + let mut authored = 0; + + for session in fixture["sessions"].as_array().expect("sessions") { + for step in session["steps"].as_array().expect("steps") { + let Some(text) = step["send"]["text"].as_str() else { + continue; + }; + let Ok(frame) = serde_json::from_str::(text) else { + continue; + }; + let Some(event) = frame + .as_array() + .and_then(|items| items.get(1)) + .cloned() + .and_then(|event| serde_json::from_value::(event).ok()) + else { + continue; + }; + + authored += usize::from( + event.pubkey == pubkey + && nostream_core::crypto::verify_event_signature(&event).is_ok(), + ); + } + } + + assert!( + authored > 0, + "the recorded client secret authors a frame whose signature verifies" + ); } From 7c3901de85c4a9444ee752e1fc01c85b174ac764 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Fri, 23 Oct 2026 17:52:49 -0400 Subject: [PATCH 10/20] test(relay): require a mapping to name a live test --- crates/nostream-relay/tests/ts_case_parity.rs | 44 +++++++++++++++---- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/crates/nostream-relay/tests/ts_case_parity.rs b/crates/nostream-relay/tests/ts_case_parity.rs index 6ba81d159..8bfbd2be4 100644 --- a/crates/nostream-relay/tests/ts_case_parity.rs +++ b/crates/nostream-relay/tests/ts_case_parity.rs @@ -65,24 +65,40 @@ fn workspace_root() -> PathBuf { .to_path_buf() } -/// Whether `source` defines `fn name` and, if it does, whether that function is `#[ignore]`d. -fn test_function(source: &str, name: &str) -> Option { +/// What a mapping row's target is: `None` when no such function is defined, and otherwise +/// whether it is a test the harness runs. +/// +/// A name alone is not enough: a mapping retargeted at a private helper would otherwise pass +/// every test in this file, and the census would count function names instead of live tests. +/// The walk demands a `#[test]` (or a `#[tokio::test]`) in the attribute block, and reports +/// `#[ignore]` from the same block. +fn test_function(source: &str, name: &str) -> Option { let needle = format!("fn {name}("); let lines: Vec<&str> = source.lines().collect(); let index = lines.iter().position(|line| line.contains(&needle))?; // Walk up over the attribute and doc-comment block that decorates the function. + let mut test = false; let mut ignored = false; let mut above = index; while above > 0 { let line = lines[above - 1].trim(); - if line.starts_with("#[") || line.starts_with("///") || line.starts_with("//") { + if line.starts_with("#[") { if line.starts_with("#[ignore") { ignored = true; } + if line.starts_with("#[test") || line.starts_with("#[tokio::test") { + test = true; + } + + above -= 1; + continue; + } + + if line.starts_with("///") || line.starts_with("//") { above -= 1; continue; } @@ -90,8 +106,13 @@ fn test_function(source: &str, name: &str) -> Option { break; } - // A one-line declaration (`fn name() { … }`) must still end the walk at a declaration. - Some(ignored) + Some(TestFunction { test, ignored }) +} + +/// What the census found at a mapping's target. +struct TestFunction { + test: bool, + ignored: bool, } #[test] @@ -214,6 +235,7 @@ fn every_mapped_test_exists_and_is_not_ignored() { let root = workspace_root(); let mut checked = 0; + let mut functions: std::collections::BTreeSet = std::collections::BTreeSet::new(); let mut exempt = 0; let mut titles_in_exempt = 0; @@ -247,8 +269,12 @@ fn every_mapped_test_exists_and_is_not_ignored() { let source = std::fs::read_to_string(&file).expect("the mapped source"); match test_function(&source, function) { - Some(false) => checked += 1, - Some(true) => panic!("{target} is #[ignore]d, so it carries nothing"), + Some(found) if found.ignored => panic!("{target} is #[ignore]d, so it carries nothing"), + Some(found) if found.test => { + checked += 1; + functions.insert(target.to_owned()); + } + Some(_) => panic!("{target} is not a #[test] or #[tokio::test], so it carries nothing"), None => panic!("{target}: no such test function in {}", file.display()), } } @@ -256,10 +282,12 @@ fn every_mapped_test_exists_and_is_not_ignored() { assert!(checked > 0, "the map names no tests at all"); println!( - "census: {} cases, {} mapped to {} live tests, {} exempt groups holding {} cases", + "census: {} cases, {} mapped across {} mapping rows to {} test functions, \ + {} exempt groups holding {} cases", map["cases"].as_u64().unwrap_or(0), map["cases"].as_u64().unwrap_or(0) as usize - titles_in_exempt, checked, + functions.len(), exempt, titles_in_exempt ); From 5d4c4c3cad733578550ea7cd668d55bdc64d76db Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Fri, 23 Oct 2026 17:52:49 -0400 Subject: [PATCH 11/20] fix(relay): answer a store error with the generic failure, not a refusal --- crates/nostream-relay/src/handlers/event.rs | 72 ++++++++++++++------- crates/nostream-relay/src/testing.rs | 34 +++++++++- crates/nostream-relay/tests/handlers.rs | 51 +++++++++++++++ 3 files changed, 129 insertions(+), 28 deletions(-) diff --git a/crates/nostream-relay/src/handlers/event.rs b/crates/nostream-relay/src/handlers/event.rs index ef7c47b9a..043295db4 100644 --- a/crates/nostream-relay/src/handlers/event.rs +++ b/crates/nostream-relay/src/handlers/event.rs @@ -31,6 +31,27 @@ const AUTH_KIND: Kind = Kind(22_242); /// `EventKinds.NIP43_JOIN_REQUEST`, `NIP43_INVITE_REQUEST`, `NIP43_LEAVE_REQUEST`. const NIP43_REQUEST_KINDS: [Kind; 3] = [Kind(28_934), Kind(28_935), Kind(28_936)]; +/// The capture's answer when the event could not be processed for a reason that is not a +/// decision about the publisher (`event-message-handler.ts:360-374` lets such an error +/// escape; the adapter sends no frame, and this is the frame the port sends instead). +const UNAVAILABLE: &str = "error: unable to process event"; + +/// What an admission gate answered. +enum Gate { + /// The gate decided, and the client is told its reason. + Refused(&'static str), + /// The gate could not read its store: not a policy decision, so not a policy string. + Unavailable, +} + +/// Answers a gate's outcome with the frame its answer calls for. +fn answer_gate(context: &HandlerContext, event_id: nostream_core::EventId, gate: Gate) { + match gate { + Gate::Refused(reason) => context.send_ok(event_id, false, reason, None), + Gate::Unavailable => context.send_ok(event_id, false, UNAVAILABLE, None), + } +} + /// The handler's entry point (`handleMessage`). pub async fn handle(event: Event, context: &HandlerContext) { if let Some(reason) = is_event_valid(&event, context.relay_url()) { @@ -51,7 +72,7 @@ pub async fn handle(event: Event, context: &HandlerContext) { Ok(keys) => keys.public_key, Err(error) => { tracing::debug!(error = %error, "no relay signing key; refusing the event"); - context.send_ok(event.id, false, "error: unable to process event", None); + context.send_ok(event.id, false, UNAVAILABLE, None); return; } }; @@ -94,19 +115,18 @@ pub async fn handle(event: Event, context: &HandlerContext) { return; } - if let Some(reason) = is_blocked_by_request_to_vanish(&event, context, &relay_public_key).await - { - context.send_ok(event.id, false, &reason, None); + if let Some(gate) = is_blocked_by_request_to_vanish(&event, context, &relay_public_key).await { + answer_gate(context, event.id, gate); return; } - if let Some(reason) = is_user_admitted(&event, context, &relay_public_key).await { - context.send_ok(event.id, false, &reason, None); + if let Some(gate) = is_user_admitted(&event, context, &relay_public_key).await { + answer_gate(context, event.id, gate); return; } - if let Some(reason) = check_nip05_verification(&event, context, &relay_public_key).await { - context.send_ok(event.id, false, &reason, None); + if let Some(gate) = check_nip05_verification(&event, context, &relay_public_key).await { + answer_gate(context, event.id, gate); return; } @@ -136,7 +156,7 @@ pub async fn handle(event: Event, context: &HandlerContext) { other => tracing::error!(error = %other, "event strategy failed"), } - context.send_ok(event.id, false, "error: unable to process event", None); + context.send_ok(event.id, false, UNAVAILABLE, None); } } @@ -431,7 +451,7 @@ async fn is_blocked_by_request_to_vanish( event: &Event, context: &HandlerContext, relay_public_key: &nostream_core::Pubkey, -) -> Option { +) -> Option { if event.kind == Kind::REQUEST_TO_VANISH { return None; } @@ -441,11 +461,13 @@ async fn is_blocked_by_request_to_vanish( } match context.stores.users.is_vanished(&event.pubkey).await { - Ok(true) => Some("blocked: request to vanish active for pubkey".to_owned()), + Ok(true) => Some(Gate::Refused( + "blocked: request to vanish active for pubkey", + )), Ok(false) => None, Err(error) => { - tracing::error!(error = %error, "admission read failed; refusing the event"); - Some("blocked: pubkey not admitted".to_owned()) + tracing::error!(error = %error, "admission read failed"); + Some(Gate::Unavailable) } } } @@ -455,7 +477,7 @@ async fn is_user_admitted( event: &Event, context: &HandlerContext, relay_public_key: &nostream_core::Pubkey, -) -> Option { +) -> Option { let settings: &Settings = context.settings.as_ref(); let payments_enabled = settings .payments @@ -528,13 +550,13 @@ async fn is_user_admitted( let user = match context.stores.users.find_by_pubkey(&event.pubkey).await { Ok(user) => user, Err(error) => { - tracing::error!(error = %error, "user read failed; refusing the event"); - return Some(not_admitted_reason.to_owned()); + tracing::error!(error = %error, "user read failed"); + return Some(Gate::Unavailable); } }; let Some(user) = user.filter(|user| user.is_admitted) else { - return Some(not_admitted_reason.to_owned()); + return Some(Gate::Refused(not_admitted_reason)); }; let min_balance = if payments_enabled { @@ -549,7 +571,7 @@ async fn is_user_admitted( }; if min_balance > 0 && user.balance < min_balance { - return Some("blocked: insufficient balance".to_owned()); + return Some(Gate::Refused("blocked: insufficient balance")); } None @@ -563,7 +585,7 @@ async fn check_nip05_verification( event: &Event, context: &HandlerContext, relay_public_key: &nostream_core::Pubkey, -) -> Option { +) -> Option { let settings = context.settings.nip05.as_ref()?; if settings.mode != nostream_config::settings::Nip05Mode::Enabled { @@ -581,13 +603,13 @@ async fn check_nip05_verification( let verification = match context.stores.nip05.find_by_pubkey(&event.pubkey).await { Ok(verification) => verification, Err(error) => { - tracing::error!(error = %error, "nip05 read failed; refusing the event"); - return Some("blocked: NIP-05 verification required".to_owned()); + tracing::error!(error = %error, "nip05 read failed"); + return Some(Gate::Unavailable); } }; let Some(verification) = verification else { - return Some("blocked: NIP-05 verification required".to_owned()); + return Some(Gate::Refused("blocked: NIP-05 verification required")); }; if !nip05::is_domain_allowed( @@ -595,11 +617,11 @@ async fn check_nip05_verification( settings.domain_whitelist.as_deref(), settings.domain_blacklist.as_deref(), ) { - return Some("blocked: NIP-05 domain not allowed".to_owned()); + return Some(Gate::Refused("blocked: NIP-05 domain not allowed")); } let Some(last_verified_at) = verification.last_verified_at else { - return Some("blocked: NIP-05 verification required".to_owned()); + return Some(Gate::Refused("blocked: NIP-05 verification required")); }; let expiration = settings @@ -613,7 +635,7 @@ async fn check_nip05_verification( .saturating_sub(u64::try_from(last_verified_at.timestamp_millis()).unwrap_or_default()); if elapsed > u64::try_from(expiration).unwrap_or(u64::MAX) { - return Some("blocked: NIP-05 verification expired".to_owned()); + return Some(Gate::Refused("blocked: NIP-05 verification expired")); } None diff --git a/crates/nostream-relay/src/testing.rs b/crates/nostream-relay/src/testing.rs index 94d170770..2bc5450d9 100644 --- a/crates/nostream-relay/src/testing.rs +++ b/crates/nostream-relay/src/testing.rs @@ -370,6 +370,7 @@ impl EventRepository for InMemoryEventRepository { #[derive(Debug, Default)] pub struct InMemoryUserRepository { users: Mutex>, + fail_reads: AtomicBool, } impl InMemoryUserRepository { @@ -378,6 +379,11 @@ impl InMemoryUserRepository { Arc::new(Self::default()) } + /// Makes every read fail, as an unavailable database would. + pub fn fail_reads(&self) { + self.fail_reads.store(true, Ordering::SeqCst); + } + /// Adds or replaces one user row. pub fn insert(&self, user: User) { self.users @@ -389,6 +395,14 @@ impl InMemoryUserRepository { impl UserRepository for InMemoryUserRepository { fn find_by_pubkey<'a>(&'a self, pubkey: &'a Pubkey) -> StorageFuture<'a, Option> { + if self.fail_reads.load(Ordering::SeqCst) { + return Box::pin(async move { + Err(StorageError::InvalidArgument { + message: "in-memory double: the user read failed", + }) + }); + } + Box::pin(async move { Ok(self .users @@ -500,6 +514,7 @@ impl UserRepository for InMemoryUserRepository { #[derive(Debug, Default)] pub struct InMemoryNip05Repository { rows: Mutex>, + fail_reads: AtomicBool, } impl InMemoryNip05Repository { @@ -508,6 +523,11 @@ impl InMemoryNip05Repository { Arc::new(Self::default()) } + /// Makes every read fail, as an unavailable database would. + pub fn fail_reads(&self) { + self.fail_reads.store(true, Ordering::SeqCst); + } + /// Adds or replaces one verification row. pub fn insert(&self, verification: Nip05Verification) { self.rows @@ -522,6 +542,14 @@ impl Nip05VerificationRepository for InMemoryNip05Repository { &'a self, pubkey: &'a Pubkey, ) -> StorageFuture<'a, Option> { + if self.fail_reads.load(Ordering::SeqCst) { + return Box::pin(async move { + Err(StorageError::InvalidArgument { + message: "in-memory double: the nip05 read failed", + }) + }); + } + Box::pin(async move { Ok(self .rows @@ -850,7 +878,7 @@ pub struct Fixture { pub invites: Arc, pub reports: Arc, pub hidden: Arc, - pub hub: tokio::sync::broadcast::Sender, + pub hub: tokio::sync::broadcast::Sender>, } impl Fixture { @@ -975,7 +1003,7 @@ impl Fixture { /// A broadcast receiver over the fixture's hub. pub fn hub_receiver( - hub: &tokio::sync::broadcast::Sender, -) -> tokio::sync::broadcast::Receiver { + hub: &tokio::sync::broadcast::Sender>, +) -> tokio::sync::broadcast::Receiver> { hub.subscribe() } diff --git a/crates/nostream-relay/tests/handlers.rs b/crates/nostream-relay/tests/handlers.rs index 2c04fdc0d..fab86e40c 100644 --- a/crates/nostream-relay/tests/handlers.rs +++ b/crates/nostream-relay/tests/handlers.rs @@ -721,6 +721,57 @@ async fn a_failed_insert_answers_with_the_generic_error() { ); } +#[tokio::test] +async fn a_store_error_is_reported_as_a_failure_and_not_as_a_refusal() { + // The capture lets a store error escape `handleMessage` and the adapter sends no frame; + // the port answers the generic failure. A gate that could not read its store must not + // tell the publisher it was refused: the message would be false, and on a NIP-43 relay it + // would send them down a join flow that cannot help. + let mut nip43 = settings(); + nip43.nip43 = Some(nostream_relay::testing::nip43_settings()); + + let fixture = Fixture::new(nip43); + fixture.users.fail_reads(); + + let server = start(fixture).await; + let event = signed_event(&CLIENT_SECRET, 1, vec![], "unreadable", NOW_SECS); + + assert_eq!( + send_event(&server, &event).await, + format!( + r#"["OK","{}",false,"error: unable to process event"]"#, + event.id + ) + ); +} + +#[tokio::test] +async fn a_nip05_store_error_is_reported_as_a_failure_and_not_as_a_refusal() { + let mut enabled = settings(); + enabled.nip05 = Some(nostream_config::settings::Nip05Settings { + mode: nostream_config::settings::Nip05Mode::Enabled, + verify_expiration: None, + verify_update_frequency: None, + max_consecutive_failures: None, + domain_whitelist: None, + domain_blacklist: None, + }); + + let fixture = Fixture::new(enabled); + fixture.nip05.fail_reads(); + + let server = start(fixture).await; + let event = signed_event(&CLIENT_SECRET, 1, vec![], "unreadable", NOW_SECS); + + assert_eq!( + send_event(&server, &event).await, + format!( + r#"["OK","{}",false,"error: unable to process event"]"#, + event.id + ) + ); +} + #[tokio::test] async fn without_a_key_an_event_cannot_be_processed() { let server = Server::start_with( From 11f8e057d8d65958534a4ffac35ac037e6949419 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Fri, 23 Oct 2026 17:54:04 -0400 Subject: [PATCH 12/20] refactor(relay): one construction path, one strategy shape, shared events --- CLAUDE.md | 7 +- crates/nostream-core/src/messages.rs | 36 ++++- crates/nostream-core/tests/wire_codec.rs | 2 +- crates/nostream-relay/Cargo.toml | 3 - crates/nostream-relay/src/connection.rs | 6 +- crates/nostream-relay/src/error.rs | 8 - crates/nostream-relay/src/handlers/mod.rs | 8 +- .../nostream-relay/src/handlers/subscribe.rs | 15 +- crates/nostream-relay/src/hidden_content.rs | 4 +- crates/nostream-relay/src/lib.rs | 7 +- crates/nostream-relay/src/relay_key.rs | 61 +++++++- crates/nostream-relay/src/sink.rs | 13 +- .../src/strategies/contact_list.rs | 26 +++- .../nostream-relay/src/strategies/default.rs | 4 +- .../nostream-relay/src/strategies/delete.rs | 26 +++- .../src/strategies/dvm_job_request.rs | 26 +++- .../src/strategies/ephemeral.rs | 26 +++- .../src/strategies/gift_wrap.rs | 26 +++- crates/nostream-relay/src/strategies/group.rs | 26 +++- .../src/strategies/invite_request.rs | 26 +++- .../src/strategies/join_request.rs | 26 +++- .../src/strategies/leave_request.rs | 26 +++- crates/nostream-relay/src/strategies/mod.rs | 143 ++++++++++++++---- .../strategies/parameterized_replaceable.rs | 26 +++- .../src/strategies/replaceable.rs | 26 +++- .../nostream-relay/src/strategies/report.rs | 26 +++- .../src/strategies/timestamp.rs | 26 +++- .../nostream-relay/src/strategies/vanish.rs | 26 +++- crates/nostream-relay/src/transport.rs | 30 ++-- crates/nostream-relay/tests/common/mod.rs | 7 +- crates/nostream-storage/src/lib.rs | 4 +- crates/nostreamd/src/acceptor.rs | 19 +-- crates/nostreamd/src/main.rs | 9 +- crates/nostreamd/tests/acceptor.rs | 13 +- crates/nostreamd/tests/wire.rs | 4 +- docs/rewrite/decisions/0004-wire-transport.md | 81 ++++++++-- 36 files changed, 646 insertions(+), 202 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b786dc485..69d5dd507 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,11 +139,12 @@ resolver 3: recorded per case.** When a message or an exemption must change, change the fixture in the same commit and say why; never loosen the comparison to make a case pass. -## Porting discipline (this branch, phase 1) +## Porting discipline (this branch, phase 2) - **Stubs are labelled, not hidden.** Every unimplemented item carries - `/// Phase 1 stub — job 005; ; see ` on the item, takes typed - arguments, and returns `NotImplemented`. `grep -rn 'Phase 1 stub' crates` must count + `/// Phase 2 stub — job NNN; ; see ` on the item (a stub left + from phase 1 keeps its `Phase 1 stub` label until a job touches it), takes typed + arguments, and returns `NotImplemented`. `grep -rn 'Phase [12] stub' crates` must count the stubs. - **No silent `Ok(())`, no plausible fake data, no partial port.** A function that would need real behaviour returns the error. A `Result`-returning stub whose body is diff --git a/crates/nostream-core/src/messages.rs b/crates/nostream-core/src/messages.rs index 14d507b3b..6abd0b04f 100644 --- a/crates/nostream-core/src/messages.rs +++ b/crates/nostream-core/src/messages.rs @@ -94,10 +94,14 @@ pub struct EoseMessage { } /// `["EVENT", , ]` (`src/@types/messages.ts` `OutgoingEventMessage`). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +/// +/// The event is shared: the hub hands one `Arc` to every connection and each matching +/// subscription clones the pointer, not the event, so a fan-out costs one increment per +/// receiver rather than a copy of the payload. +#[derive(Debug, Clone, PartialEq, Eq)] pub struct OutgoingEventMessage { pub subscription_id: SubscriptionId, - pub event: Event, + pub event: std::sync::Arc, } /// `["COUNT", , {"count": }]` (`src/@types/messages.ts` @@ -197,12 +201,7 @@ impl RelayMessage { ), }, Self::Eose(message) => frame("EOSE", &["e(&message.subscription_id)]), - Self::Event(message) => { - let event = - serde_json::to_string(&message.event).unwrap_or_else(|_| "null".to_owned()); - - frame("EVENT", &["e(&message.subscription_id), &event]) - } + Self::Event(message) => event_frame(message), Self::Count(message) => frame( "COUNT", &[ @@ -255,6 +254,27 @@ impl From for RelayMessage { } } +/// `["EVENT", , ]` in one pass: the tuple serializes straight into the frame's +/// buffer, so the event's JSON is written once instead of into a `String` and then copied +/// into the frame. +fn event_frame(message: &OutgoingEventMessage) -> String { + // Sized for one write: the fixed fields and both hex halves, the content as it stands + // (escaping can grow it, and then the buffer grows once), and about one tag element. + let mut buffer: Vec = + Vec::with_capacity(512 + message.event.content.len() + 96 * message.event.tags.len()); + + let written = serde_json::to_writer( + &mut buffer, + &("EVENT", &message.subscription_id, &*message.event), + ); + + match written { + Ok(()) => String::from_utf8(buffer) + .unwrap_or_else(|_| frame("EVENT", &["e(&message.subscription_id), "null"])), + Err(_) => frame("EVENT", &["e(&message.subscription_id), "null"]), + } +} + fn frame(verb: &str, parts: &[&str]) -> String { let mut text = format!("[{}", quote(verb)); diff --git a/crates/nostream-core/tests/wire_codec.rs b/crates/nostream-core/tests/wire_codec.rs index e4e6980c3..0ea156e06 100644 --- a/crates/nostream-core/tests/wire_codec.rs +++ b/crates/nostream-core/tests/wire_codec.rs @@ -62,7 +62,7 @@ fn event_frame_is_the_verb_the_id_and_the_event_in_wire_order() { let event = event(); let frame = RelayMessage::Event(OutgoingEventMessage { subscription_id: "subscriptionId".to_owned(), - event: event.clone(), + event: std::sync::Arc::new(event.clone()), }); assert_eq!( diff --git a/crates/nostream-relay/Cargo.toml b/crates/nostream-relay/Cargo.toml index fa18dff0c..122360419 100644 --- a/crates/nostream-relay/Cargo.toml +++ b/crates/nostream-relay/Cargo.toml @@ -22,8 +22,5 @@ tokio.workspace = true tokio-tungstenite.workspace = true tracing.workspace = true -[dev-dependencies] -hex.workspace = true - [lints] workspace = true diff --git a/crates/nostream-relay/src/connection.rs b/crates/nostream-relay/src/connection.rs index 370e451d5..5120468c5 100644 --- a/crates/nostream-relay/src/connection.rs +++ b/crates/nostream-relay/src/connection.rs @@ -13,7 +13,6 @@ use std::sync::{Arc, Mutex}; use tokio::sync::watch; use nostream_config::settings::Settings; -use nostream_core::clock::Clock; use nostream_core::nip42::Nip42Session; use nostream_core::random::RandomSource; use nostream_core::{Event, Filter, Pubkey}; @@ -275,11 +274,10 @@ impl Connection { } } -/// The state a connection is accepted with: the settings it reads through, the clock, and -/// the sink its challenge leaves through. +/// The state a connection is accepted with: the settings it reads through, the randomness its +/// challenge is minted from, and the sink the challenge leaves through. pub struct ConnectionContext { pub settings: Arc, - pub clock: Arc, pub random: Arc, pub sink: FrameSink, } diff --git a/crates/nostream-relay/src/error.rs b/crates/nostream-relay/src/error.rs index c5d59d539..3e1a1d776 100644 --- a/crates/nostream-relay/src/error.rs +++ b/crates/nostream-relay/src/error.rs @@ -15,10 +15,6 @@ pub enum RelayError { #[error("invalid message: {0}")] InvalidMessage(String), - /// The socket is closing under drain. - #[error("the relay is draining and refuses new work")] - Draining, - /// A repository call failed. #[error(transparent)] Storage(#[from] StorageError), @@ -34,8 +30,4 @@ pub enum RelayError { /// The signed websocket handshake failed. #[error("websocket handshake: {0}")] Handshake(String), - - /// A frame could not be written to the socket. - #[error("websocket write: {0}")] - Transport(String), } diff --git a/crates/nostream-relay/src/handlers/mod.rs b/crates/nostream-relay/src/handlers/mod.rs index 948c3659b..72bc801ab 100644 --- a/crates/nostream-relay/src/handlers/mod.rs +++ b/crates/nostream-relay/src/handlers/mod.rs @@ -63,7 +63,7 @@ pub struct HandlerContext { pub limits: Arc, pub hidden: Arc, pub keys: Arc, - pub broadcast: broadcast::Sender, + pub broadcast: broadcast::Sender>, pub pow: Arc>, } @@ -99,8 +99,10 @@ impl HandlerContext { restricted_read_kinds(self.settings.nip42.as_ref()) } - /// `createReadAuthorizationGuard` bound to this socket's authenticated set: the caller - /// takes one snapshot, and every event in the read is judged against it. + /// `createReadAuthorizationGuard` bound to this socket's authenticated set: the snapshot + /// is taken when the guard is built, so a caller that wants the capture's per-event + /// behaviour builds one per event (the stored read does; the live fan-out builds one per + /// dispatch). pub fn read_authorization_guard(&self) -> ReadGuard { ReadGuard { restricted: self.restricted_read_kinds(), diff --git a/crates/nostream-relay/src/handlers/subscribe.rs b/crates/nostream-relay/src/handlers/subscribe.rs index 97e8f030f..11d5581b9 100644 --- a/crates/nostream-relay/src/handlers/subscribe.rs +++ b/crates/nostream-relay/src/handlers/subscribe.rs @@ -85,7 +85,7 @@ async fn maybe_issue_invite_code( .sink .send(&RelayMessage::Event(OutgoingEventMessage { subscription_id: subscription_id.to_owned(), - event, + event: std::sync::Arc::new(event), })); } Ok(None) => {} @@ -214,11 +214,6 @@ async fn fetch_and_send(subscription_id: &str, filters: &[Filter], context: &Han futures_util::pin_mut!(stream); let now = context.now_secs(); - let authenticated = context.authenticated_pubkeys(); - let restricted = context.restricted_read_kinds(); - let read_authorized = |event: &nostream_core::Event| { - nip42::is_read_authorized(event, &restricted, &authenticated) - }; let matches_any = |event: &nostream_core::Event| filters.iter().any(|filter| filter.matches(event)); @@ -252,8 +247,12 @@ async fn fetch_and_send(subscription_id: &str, filters: &[Filter], context: &Han let event = record.event; + // The guard is the crate's one definition of "may this socket read this + // event"; it is asked per event, as the capture's + // `createReadAuthorizationGuard` is, so an AUTH that lands mid-read applies to + // the rest of the stream. if expiration::is_expired_event(&event, now) - || !read_authorized(&event) + || !context.read_authorization_guard().allows(&event) || !matches_any(&event) { continue; @@ -263,7 +262,7 @@ async fn fetch_and_send(subscription_id: &str, filters: &[Filter], context: &Han .sink .send(&RelayMessage::Event(OutgoingEventMessage { subscription_id: subscription_id.to_owned(), - event, + event: std::sync::Arc::new(event), })); } Err(error) => { diff --git a/crates/nostream-relay/src/hidden_content.rs b/crates/nostream-relay/src/hidden_content.rs index 14fbe49fd..61fc4d12a 100644 --- a/crates/nostream-relay/src/hidden_content.rs +++ b/crates/nostream-relay/src/hidden_content.rs @@ -134,7 +134,9 @@ impl HiddenContentCache { Ok(()) } - /// Seeds the mirror, then re-reads it on `interval` until the returned task is dropped. + /// Seeds the mirror, then re-reads it on `interval` until the returned handle is + /// **aborted** (dropping it detaches the task rather than stopping it, so the caller owns + /// the handle and [`crate::relay::Relay::serve`] aborts it when its loop ends). /// /// `interval` is injectable (`startHiddenContentCache`'s third argument); the daemon /// passes [`DEFAULT_REFRESH_INTERVAL`]. diff --git a/crates/nostream-relay/src/lib.rs b/crates/nostream-relay/src/lib.rs index cb52e20a6..2c17dfd61 100644 --- a/crates/nostream-relay/src/lib.rs +++ b/crates/nostream-relay/src/lib.rs @@ -11,7 +11,7 @@ //! - `src/relay-broadcast/redis-relay-broadcast-fanout.ts` //! - `src/controllers/admission/get-admission-check-controller.ts`, //! `src/routes/admissions/index.ts` -//! - `src/utils/{sliding-window-rate-limiter,ewma-rate-limiter, +//! - `src/utils/{sliding-window-rate-limiter,ewma-rate-limiter,hidden-content-cache, //! relay-broadcast-deduplicator,relay-broadcast-message,relay-broadcast-state}.ts` //! - `src/@types/{message-handlers,subscription,clients}.ts` //! - `src/factories/{event-strategy-factory,message-handler-factory, @@ -29,8 +29,9 @@ //! event strategies (job 009), `NegentropySession`/`open_negentropy_session` (a NIP-77 job //! cut against `041fe34e`, where the `neg-*` handlers first exist), and the cross-worker //! broadcast `publish`/`deduplicate` (unassigned: `src/relay-broadcast/redis-relay-broadcast-fanout.ts` -//! is named by no bullet of issue #865). `broadcast.rs` therefore carries no behaviour; the -//! in-process fan-out this job needs is `Relay::hub`. +//! is named by no bullet of issue #865, which is why `broadcast.rs` still carries phase-1 +//! stub labels naming job 005, and the job may be re-cut). `broadcast.rs` therefore carries no +//! behaviour; the in-process fan-out this job needs is the relay's own broadcast channel. //! //! Phase 2+ areas this crate does not own: the HTTP surface and NIP-11 document //! (`nostream-admin`), and the probe family and NIP-66 monitor (`nostream-services`). diff --git a/crates/nostream-relay/src/relay_key.rs b/crates/nostream-relay/src/relay_key.rs index 310384f61..e8b02dfda 100644 --- a/crates/nostream-relay/src/relay_key.rs +++ b/crates/nostream-relay/src/relay_key.rs @@ -27,7 +27,7 @@ type HmacSha256 = Hmac; pub struct RelayKeySource { relay_private_key: Option, secret: Option, - resolved: Arc>>, + resolved: Arc>>, } impl RelayKeySource { @@ -49,13 +49,22 @@ impl RelayKeySource { } /// The relay's key pair, or [`RelayError::Configuration`] when neither source is set. + /// + /// The pair is memoised with the URL it was derived from: `SECRET` seeds the key from the + /// relay's URL, so a second call with another URL must derive again rather than inherit + /// the first one's key. pub fn resolve(&self, relay_url: &str) -> Result { - if let Some(keys) = *self - .resolved - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) { - return Ok(keys); + let resolved = self + .resolved + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + if let Some((url, keys)) = resolved.as_ref() + && url == relay_url + { + return Ok(*keys); + } } let key = match (&self.relay_private_key, &self.secret) { @@ -77,7 +86,7 @@ impl RelayKeySource { *self .resolved .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(keys); + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((relay_url.to_owned(), keys)); Ok(keys) } @@ -119,3 +128,41 @@ fn derive_from_secret(secret: &str, purpose: &str) -> Result<[u8; 32], RelayErro Ok(mac.finalize().into_bytes().into()) } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use super::*; + + #[test] + fn a_secret_derived_key_is_memoised_per_relay_url() { + // `SECRET` seeds the key from the relay's URL, so the memo has to remember which URL + // produced the pair it holds. + let source = RelayKeySource::from_values(None, Some("operator-secret".to_owned())); + + let first = source.resolve("wss://one.example").expect("a key"); + let again = source + .resolve("wss://one.example") + .expect("the memoised key"); + let other = source.resolve("wss://two.example").expect("a key"); + + assert_eq!(first, again); + assert_ne!( + first.public_key, other.public_key, + "a second URL derives a second key" + ); + } + + #[test] + fn an_explicit_private_key_does_not_depend_on_the_url() { + // `01` repeated is a valid secp256k1 scalar; all zeros is not. + let source = + RelayKeySource::from_values(Some("01".repeat(32)), Some("operator-secret".to_owned())); + + assert_eq!( + source.resolve("wss://one.example").expect("a key"), + source.resolve("wss://two.example").expect("a key") + ); + } +} diff --git a/crates/nostream-relay/src/sink.rs b/crates/nostream-relay/src/sink.rs index 72a94581e..2dfa8be7f 100644 --- a/crates/nostream-relay/src/sink.rs +++ b/crates/nostream-relay/src/sink.rs @@ -1,10 +1,15 @@ //! The one write point for server→client frames. Mirrors `WebSocketAdapter.sendMessage` //! (`src/adapters/web-socket-adapter.ts:150-155`). //! -//! Every frame the relay sends — handler replies, subscription reads, live dispatch, drain -//! notices, the NIP-42 challenge — goes through [`FrameSink`]. Handlers and strategies hold -//! a sink and never touch the transport; the connection task is the only writer, and it -//! encodes through [`RelayMessage::to_text`], so frame encoding happens in one place. +//! Every JSON frame the relay sends — handler replies, subscription reads, live dispatch, the +//! NIP-42 challenge — goes through [`FrameSink`]. Handlers and strategies hold a sink and +//! never touch the transport; the connection task is the only writer, and it encodes through +//! [`RelayMessage::to_text`], so frame encoding happens in one place. +//! +//! Two writes deliberately skip the queue, and both are in the connection task that drains +//! it: the drain's `CLOSED` frames go straight to the socket because the loop that would +//! drain the queue is the one being left, and the liveness `Ping` is a control frame rather +//! than JSON. //! //! A send on a socket that is no longer open is dropped silently, which is what the //! capture's `readyState !== OPEN` guard does: it returns without an error. diff --git a/crates/nostream-relay/src/strategies/contact_list.rs b/crates/nostream-relay/src/strategies/contact_list.rs index c73852d0d..f5b03d8ae 100644 --- a/crates/nostream-relay/src/strategies/contact_list.rs +++ b/crates/nostream-relay/src/strategies/contact_list.rs @@ -1,13 +1,29 @@ //! The `contact-list` event strategy. Mirrors `src/handlers/event-strategies/contact-list-event-strategy.ts`. +use std::future::Future; +use std::pin::Pin; + use nostream_core::Event; use crate::error::RelayError; use crate::handlers::HandlerContext; +use crate::strategies::EventStrategy; + +/// The `contact_list` arm. +#[derive(Debug, Clone, Copy, Default)] +pub struct ContactListStrategy; -/// Phase 2 stub — job 009; will upsert a kind-3 contact list and refresh the WoT graph (NIP-02); see `src/handlers/event-strategies/contact-list-event-strategy.ts`. -pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { - Err(RelayError::NotImplemented { - module: "relay::strategies::contact_list", - }) +impl EventStrategy for ContactListStrategy { + /// Phase 2 stub — job 009; will upsert a kind-3 contact list and refresh the WoT graph (NIP-02); see `src/handlers/event-strategies/contact-list-event-strategy.ts`. + fn execute<'a>( + &'a self, + _event: &'a Event, + _context: &'a HandlerContext, + ) -> Pin> + Send + 'a>> { + Box::pin(async { + Err(RelayError::NotImplemented { + module: "relay::strategies::contact_list", + }) + }) + } } diff --git a/crates/nostream-relay/src/strategies/default.rs b/crates/nostream-relay/src/strategies/default.rs index 7e3ea77a7..2cf30dc2b 100644 --- a/crates/nostream-relay/src/strategies/default.rs +++ b/crates/nostream-relay/src/strategies/default.rs @@ -41,7 +41,9 @@ impl EventStrategy for DefaultStrategy { if count > 0 { // A closed hub means no connection is listening; the event is still stored. - let _ = context.broadcast.send(event.clone()); + // The hub carries a shared event, so every connection's fan-out is one + // reference count rather than a copy of the payload. + let _ = context.broadcast.send(std::sync::Arc::new(event.clone())); } Ok(()) diff --git a/crates/nostream-relay/src/strategies/delete.rs b/crates/nostream-relay/src/strategies/delete.rs index b29aad1a3..3ec0ca26f 100644 --- a/crates/nostream-relay/src/strategies/delete.rs +++ b/crates/nostream-relay/src/strategies/delete.rs @@ -1,13 +1,29 @@ //! The `delete` event strategy. Mirrors `src/handlers/event-strategies/delete-event-strategy.ts`. +use std::future::Future; +use std::pin::Pin; + use nostream_core::Event; use crate::error::RelayError; use crate::handlers::HandlerContext; +use crate::strategies::EventStrategy; + +/// The `delete` arm. +#[derive(Debug, Clone, Copy, Default)] +pub struct DeleteStrategy; -/// Phase 2 stub — job 009; will soft-delete the referenced events (NIP-09); see `src/handlers/event-strategies/delete-event-strategy.ts`. -pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { - Err(RelayError::NotImplemented { - module: "relay::strategies::delete", - }) +impl EventStrategy for DeleteStrategy { + /// Phase 2 stub — job 009; will soft-delete the referenced events (NIP-09); see `src/handlers/event-strategies/delete-event-strategy.ts`. + fn execute<'a>( + &'a self, + _event: &'a Event, + _context: &'a HandlerContext, + ) -> Pin> + Send + 'a>> { + Box::pin(async { + Err(RelayError::NotImplemented { + module: "relay::strategies::delete", + }) + }) + } } diff --git a/crates/nostream-relay/src/strategies/dvm_job_request.rs b/crates/nostream-relay/src/strategies/dvm_job_request.rs index f67e5fb42..440feaab0 100644 --- a/crates/nostream-relay/src/strategies/dvm_job_request.rs +++ b/crates/nostream-relay/src/strategies/dvm_job_request.rs @@ -1,13 +1,29 @@ //! The `dvm-job-request` event strategy. Mirrors `src/handlers/event-strategies/dvm-job-request-event-strategy.ts`. +use std::future::Future; +use std::pin::Pin; + use nostream_core::Event; use crate::error::RelayError; use crate::handlers::HandlerContext; +use crate::strategies::EventStrategy; + +/// The `dvm_job_request` arm. +#[derive(Debug, Clone, Copy, Default)] +pub struct DvmJobRequestStrategy; -/// Phase 2 stub — job 009; will store a job row for a NIP-90 request; see `src/handlers/event-strategies/dvm-job-request-event-strategy.ts`. -pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { - Err(RelayError::NotImplemented { - module: "relay::strategies::dvm_job_request", - }) +impl EventStrategy for DvmJobRequestStrategy { + /// Phase 2 stub — job 009; will store a job row for a NIP-90 request; see `src/handlers/event-strategies/dvm-job-request-event-strategy.ts`. + fn execute<'a>( + &'a self, + _event: &'a Event, + _context: &'a HandlerContext, + ) -> Pin> + Send + 'a>> { + Box::pin(async { + Err(RelayError::NotImplemented { + module: "relay::strategies::dvm_job_request", + }) + }) + } } diff --git a/crates/nostream-relay/src/strategies/ephemeral.rs b/crates/nostream-relay/src/strategies/ephemeral.rs index f01d875c6..819d776f1 100644 --- a/crates/nostream-relay/src/strategies/ephemeral.rs +++ b/crates/nostream-relay/src/strategies/ephemeral.rs @@ -1,13 +1,29 @@ //! The `ephemeral` event strategy. Mirrors `src/handlers/event-strategies/ephemeral-event-strategy.ts`. +use std::future::Future; +use std::pin::Pin; + use nostream_core::Event; use crate::error::RelayError; use crate::handlers::HandlerContext; +use crate::strategies::EventStrategy; + +/// The `ephemeral` arm. +#[derive(Debug, Clone, Copy, Default)] +pub struct EphemeralStrategy; -/// Phase 2 stub — job 009; will answer OK and broadcast without storing; see `src/handlers/event-strategies/ephemeral-event-strategy.ts`. -pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { - Err(RelayError::NotImplemented { - module: "relay::strategies::ephemeral", - }) +impl EventStrategy for EphemeralStrategy { + /// Phase 2 stub — job 009; will answer OK and broadcast without storing; see `src/handlers/event-strategies/ephemeral-event-strategy.ts`. + fn execute<'a>( + &'a self, + _event: &'a Event, + _context: &'a HandlerContext, + ) -> Pin> + Send + 'a>> { + Box::pin(async { + Err(RelayError::NotImplemented { + module: "relay::strategies::ephemeral", + }) + }) + } } diff --git a/crates/nostream-relay/src/strategies/gift_wrap.rs b/crates/nostream-relay/src/strategies/gift_wrap.rs index 35fba7521..b58b6d42c 100644 --- a/crates/nostream-relay/src/strategies/gift_wrap.rs +++ b/crates/nostream-relay/src/strategies/gift_wrap.rs @@ -1,13 +1,29 @@ //! The `gift-wrap` event strategy. Mirrors `src/handlers/event-strategies/gift-wrap-event-strategy.ts`. +use std::future::Future; +use std::pin::Pin; + use nostream_core::Event; use crate::error::RelayError; use crate::handlers::HandlerContext; +use crate::strategies::EventStrategy; + +/// The `gift_wrap` arm. +#[derive(Debug, Clone, Copy, Default)] +pub struct GiftWrapStrategy; -/// Phase 2 stub — job 009; will validate the p tag and the NIP-44 payload before storing a kind-1059 gift wrap; see `src/handlers/event-strategies/gift-wrap-event-strategy.ts`. -pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { - Err(RelayError::NotImplemented { - module: "relay::strategies::gift_wrap", - }) +impl EventStrategy for GiftWrapStrategy { + /// Phase 2 stub — job 009; will validate the p tag and the NIP-44 payload before storing a kind-1059 gift wrap; see `src/handlers/event-strategies/gift-wrap-event-strategy.ts`. + fn execute<'a>( + &'a self, + _event: &'a Event, + _context: &'a HandlerContext, + ) -> Pin> + Send + 'a>> { + Box::pin(async { + Err(RelayError::NotImplemented { + module: "relay::strategies::gift_wrap", + }) + }) + } } diff --git a/crates/nostream-relay/src/strategies/group.rs b/crates/nostream-relay/src/strategies/group.rs index bbdf1278f..bd6e4cd65 100644 --- a/crates/nostream-relay/src/strategies/group.rs +++ b/crates/nostream-relay/src/strategies/group.rs @@ -1,13 +1,29 @@ //! The `group` event strategy. Mirrors `src/handlers/event-strategies/group-event-strategy.ts`. +use std::future::Future; +use std::pin::Pin; + use nostream_core::Event; use crate::error::RelayError; use crate::handlers::HandlerContext; +use crate::strategies::EventStrategy; + +/// The `group` arm. +#[derive(Debug, Clone, Copy, Default)] +pub struct GroupStrategy; -/// Phase 2 stub — job 009; will validate the h tag on a Marmot kind-445 group event; see `src/handlers/event-strategies/group-event-strategy.ts`. -pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { - Err(RelayError::NotImplemented { - module: "relay::strategies::group", - }) +impl EventStrategy for GroupStrategy { + /// Phase 2 stub — job 009; will validate the h tag on a Marmot kind-445 group event; see `src/handlers/event-strategies/group-event-strategy.ts`. + fn execute<'a>( + &'a self, + _event: &'a Event, + _context: &'a HandlerContext, + ) -> Pin> + Send + 'a>> { + Box::pin(async { + Err(RelayError::NotImplemented { + module: "relay::strategies::group", + }) + }) + } } diff --git a/crates/nostream-relay/src/strategies/invite_request.rs b/crates/nostream-relay/src/strategies/invite_request.rs index 54bea5ad0..f9162cd0e 100644 --- a/crates/nostream-relay/src/strategies/invite_request.rs +++ b/crates/nostream-relay/src/strategies/invite_request.rs @@ -1,13 +1,29 @@ //! The `invite-request` event strategy. Mirrors `src/handlers/event-strategies/invite-request-event-strategy.ts`. +use std::future::Future; +use std::pin::Pin; + use nostream_core::Event; use crate::error::RelayError; use crate::handlers::HandlerContext; +use crate::strategies::EventStrategy; + +/// The `invite_request` arm. +#[derive(Debug, Clone, Copy, Default)] +pub struct InviteRequestStrategy; -/// Phase 2 stub — job 009; will answer a published kind-28935 with "use a REQ instead"; see `src/handlers/event-strategies/invite-request-event-strategy.ts`. -pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { - Err(RelayError::NotImplemented { - module: "relay::strategies::invite_request", - }) +impl EventStrategy for InviteRequestStrategy { + /// Phase 2 stub — job 009; will answer a published kind-28935 with "use a REQ instead"; see `src/handlers/event-strategies/invite-request-event-strategy.ts`. + fn execute<'a>( + &'a self, + _event: &'a Event, + _context: &'a HandlerContext, + ) -> Pin> + Send + 'a>> { + Box::pin(async { + Err(RelayError::NotImplemented { + module: "relay::strategies::invite_request", + }) + }) + } } diff --git a/crates/nostream-relay/src/strategies/join_request.rs b/crates/nostream-relay/src/strategies/join_request.rs index d4f791db6..ac02f3512 100644 --- a/crates/nostream-relay/src/strategies/join_request.rs +++ b/crates/nostream-relay/src/strategies/join_request.rs @@ -1,13 +1,29 @@ //! The `join-request` event strategy. Mirrors `src/handlers/event-strategies/join-request-event-strategy.ts`. +use std::future::Future; +use std::pin::Pin; + use nostream_core::Event; use crate::error::RelayError; use crate::handlers::HandlerContext; +use crate::strategies::EventStrategy; + +/// The `join_request` arm. +#[derive(Debug, Clone, Copy, Default)] +pub struct JoinRequestStrategy; -/// Phase 2 stub — job 009; will claim an invite code and admit a member (NIP-43); see `src/handlers/event-strategies/join-request-event-strategy.ts`. -pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { - Err(RelayError::NotImplemented { - module: "relay::strategies::join_request", - }) +impl EventStrategy for JoinRequestStrategy { + /// Phase 2 stub — job 009; will claim an invite code and admit a member (NIP-43); see `src/handlers/event-strategies/join-request-event-strategy.ts`. + fn execute<'a>( + &'a self, + _event: &'a Event, + _context: &'a HandlerContext, + ) -> Pin> + Send + 'a>> { + Box::pin(async { + Err(RelayError::NotImplemented { + module: "relay::strategies::join_request", + }) + }) + } } diff --git a/crates/nostream-relay/src/strategies/leave_request.rs b/crates/nostream-relay/src/strategies/leave_request.rs index 0250ad001..1187485cc 100644 --- a/crates/nostream-relay/src/strategies/leave_request.rs +++ b/crates/nostream-relay/src/strategies/leave_request.rs @@ -1,13 +1,29 @@ //! The `leave-request` event strategy. Mirrors `src/handlers/event-strategies/leave-request-event-strategy.ts`. +use std::future::Future; +use std::pin::Pin; + use nostream_core::Event; use crate::error::RelayError; use crate::handlers::HandlerContext; +use crate::strategies::EventStrategy; + +/// The `leave_request` arm. +#[derive(Debug, Clone, Copy, Default)] +pub struct LeaveRequestStrategy; -/// Phase 2 stub — job 009; will revoke a member (NIP-43); see `src/handlers/event-strategies/leave-request-event-strategy.ts`. -pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { - Err(RelayError::NotImplemented { - module: "relay::strategies::leave_request", - }) +impl EventStrategy for LeaveRequestStrategy { + /// Phase 2 stub — job 009; will revoke a member (NIP-43); see `src/handlers/event-strategies/leave-request-event-strategy.ts`. + fn execute<'a>( + &'a self, + _event: &'a Event, + _context: &'a HandlerContext, + ) -> Pin> + Send + 'a>> { + Box::pin(async { + Err(RelayError::NotImplemented { + module: "relay::strategies::leave_request", + }) + }) + } } diff --git a/crates/nostream-relay/src/strategies/mod.rs b/crates/nostream-relay/src/strategies/mod.rs index 8221a2a89..1986d50f9 100644 --- a/crates/nostream-relay/src/strategies/mod.rs +++ b/crates/nostream-relay/src/strategies/mod.rs @@ -1,10 +1,12 @@ //! The event strategies. Mirrors `src/handlers/event-strategies/*-event-strategy.ts` and the //! selection chain of `src/factories/event-strategy-factory.ts:56-108`. //! -//! The trait is `IEventStrategy.execute` (one method, `(event, sink)`); the registry -//! [`select`]s the arm in the capture's order — which **is** the policy — and [`Strategy`] -//! runs it. `default` is implemented here; the other fourteen modules are labelled stubs for -//! job 009. +//! A strategy is one type implementing [`EventStrategy`] — `IEventStrategy.execute`'s one +//! method, `(event, context)` — and every arm has that shape, filled or stubbed, so job 009 +//! adds behaviour to a type rather than choosing a second convention (ADR 0004 records the +//! signature, which departs from the capture's `(event, sink)`). [`select`] picks the arm in +//! the capture's order, which **is** the policy, and [`Strategy`] runs it. `default` is +//! implemented here; the other fourteen arms are labelled stubs for job 009. pub mod contact_list; pub mod default; @@ -29,6 +31,7 @@ use nostream_core::{Event, Kind}; use crate::error::RelayError; use crate::handlers::HandlerContext; +use crate::strategies::default::DefaultStrategy; /// One strategy's outcome (`IEventStrategy.execute`). /// @@ -117,22 +120,48 @@ impl EventStrategy for Strategy { Box::pin(async move { match selection { - Selection::Default => default::DefaultStrategy.execute(event, context).await, - Selection::Vanish => vanish::execute(event, context).await, - Selection::GiftWrap => gift_wrap::execute(event, context).await, - Selection::Group => group::execute(event, context).await, - Selection::Timestamp => timestamp::execute(event, context).await, - Selection::ContactList => contact_list::execute(event, context).await, - Selection::Report => report::execute(event, context).await, - Selection::Replaceable => replaceable::execute(event, context).await, - Selection::Join => join_request::execute(event, context).await, - Selection::Leave => leave_request::execute(event, context).await, - Selection::InviteRequest => invite_request::execute(event, context).await, - Selection::DvmJob => dvm_job_request::execute(event, context).await, - Selection::Ephemeral => ephemeral::execute(event, context).await, - Selection::Delete => delete::execute(event, context).await, + Selection::Default => DefaultStrategy.execute(event, context).await, + Selection::Vanish => vanish::VanishStrategy.execute(event, context).await, + Selection::GiftWrap => gift_wrap::GiftWrapStrategy.execute(event, context).await, + Selection::Group => group::GroupStrategy.execute(event, context).await, + Selection::Timestamp => timestamp::TimestampStrategy.execute(event, context).await, + Selection::ContactList => { + contact_list::ContactListStrategy + .execute(event, context) + .await + } + Selection::Report => report::ReportStrategy.execute(event, context).await, + Selection::Replaceable => { + replaceable::ReplaceableStrategy + .execute(event, context) + .await + } + Selection::Join => { + join_request::JoinRequestStrategy + .execute(event, context) + .await + } + Selection::Leave => { + leave_request::LeaveRequestStrategy + .execute(event, context) + .await + } + Selection::InviteRequest => { + invite_request::InviteRequestStrategy + .execute(event, context) + .await + } + Selection::DvmJob => { + dvm_job_request::DvmJobRequestStrategy + .execute(event, context) + .await + } + Selection::Ephemeral => ephemeral::EphemeralStrategy.execute(event, context).await, + Selection::Delete => delete::DeleteStrategy.execute(event, context).await, Selection::ParameterizedReplaceable => { - parameterized_replaceable::execute(event, context).await + parameterized_replaceable::ParameterizedReplaceableStrategy + .execute(event, context) + .await } } }) @@ -241,23 +270,71 @@ mod stubs { runtime.block_on(async { for (selection, module) in selections { let outcome = match selection { - Selection::Vanish => vanish::execute(&event_of(62), &context).await, - Selection::GiftWrap => gift_wrap::execute(&event_of(1059), &context).await, - Selection::Group => group::execute(&event_of(445), &context).await, - Selection::Timestamp => timestamp::execute(&event_of(1040), &context).await, - Selection::ContactList => contact_list::execute(&event_of(3), &context).await, - Selection::Report => report::execute(&event_of(1984), &context).await, - Selection::Replaceable => replaceable::execute(&event_of(0), &context).await, - Selection::Join => join_request::execute(&event_of(28934), &context).await, - Selection::Leave => leave_request::execute(&event_of(28936), &context).await, + Selection::Vanish => { + vanish::VanishStrategy + .execute(&event_of(62), &context) + .await + } + Selection::GiftWrap => { + gift_wrap::GiftWrapStrategy + .execute(&event_of(1059), &context) + .await + } + Selection::Group => { + group::GroupStrategy.execute(&event_of(445), &context).await + } + Selection::Timestamp => { + timestamp::TimestampStrategy + .execute(&event_of(1040), &context) + .await + } + Selection::ContactList => { + contact_list::ContactListStrategy + .execute(&event_of(3), &context) + .await + } + Selection::Report => { + report::ReportStrategy + .execute(&event_of(1984), &context) + .await + } + Selection::Replaceable => { + replaceable::ReplaceableStrategy + .execute(&event_of(0), &context) + .await + } + Selection::Join => { + join_request::JoinRequestStrategy + .execute(&event_of(28934), &context) + .await + } + Selection::Leave => { + leave_request::LeaveRequestStrategy + .execute(&event_of(28936), &context) + .await + } Selection::InviteRequest => { - invite_request::execute(&event_of(28935), &context).await + invite_request::InviteRequestStrategy + .execute(&event_of(28935), &context) + .await + } + Selection::DvmJob => { + dvm_job_request::DvmJobRequestStrategy + .execute(&event_of(5000), &context) + .await + } + Selection::Ephemeral => { + ephemeral::EphemeralStrategy + .execute(&event_of(20000), &context) + .await + } + Selection::Delete => { + delete::DeleteStrategy.execute(&event_of(5), &context).await } - Selection::DvmJob => dvm_job_request::execute(&event_of(5000), &context).await, - Selection::Ephemeral => ephemeral::execute(&event_of(20000), &context).await, - Selection::Delete => delete::execute(&event_of(5), &context).await, Selection::ParameterizedReplaceable => { - parameterized_replaceable::execute(&event_of(30000), &context).await + parameterized_replaceable::ParameterizedReplaceableStrategy + .execute(&event_of(30000), &context) + .await } Selection::Default => unreachable!("default is implemented"), }; diff --git a/crates/nostream-relay/src/strategies/parameterized_replaceable.rs b/crates/nostream-relay/src/strategies/parameterized_replaceable.rs index d5e43dd9b..e30cdf996 100644 --- a/crates/nostream-relay/src/strategies/parameterized_replaceable.rs +++ b/crates/nostream-relay/src/strategies/parameterized_replaceable.rs @@ -1,13 +1,29 @@ //! The `parameterized-replaceable` event strategy. Mirrors `src/handlers/event-strategies/parameterized-replaceable-event-strategy.ts`. +use std::future::Future; +use std::pin::Pin; + use nostream_core::Event; use crate::error::RelayError; use crate::handlers::HandlerContext; +use crate::strategies::EventStrategy; + +/// The `parameterized_replaceable` arm. +#[derive(Debug, Clone, Copy, Default)] +pub struct ParameterizedReplaceableStrategy; -/// Phase 2 stub — job 009; will upsert an addressable event by its d tag (NIP-33); see `src/handlers/event-strategies/parameterized-replaceable-event-strategy.ts`. -pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { - Err(RelayError::NotImplemented { - module: "relay::strategies::parameterized_replaceable", - }) +impl EventStrategy for ParameterizedReplaceableStrategy { + /// Phase 2 stub — job 009; will upsert an addressable event by its d tag (NIP-33); see `src/handlers/event-strategies/parameterized-replaceable-event-strategy.ts`. + fn execute<'a>( + &'a self, + _event: &'a Event, + _context: &'a HandlerContext, + ) -> Pin> + Send + 'a>> { + Box::pin(async { + Err(RelayError::NotImplemented { + module: "relay::strategies::parameterized_replaceable", + }) + }) + } } diff --git a/crates/nostream-relay/src/strategies/replaceable.rs b/crates/nostream-relay/src/strategies/replaceable.rs index 95e080585..45ef31f52 100644 --- a/crates/nostream-relay/src/strategies/replaceable.rs +++ b/crates/nostream-relay/src/strategies/replaceable.rs @@ -1,13 +1,29 @@ //! The `replaceable` event strategy. Mirrors `src/handlers/event-strategies/replaceable-event-strategy.ts`. +use std::future::Future; +use std::pin::Pin; + use nostream_core::Event; use crate::error::RelayError; use crate::handlers::HandlerContext; +use crate::strategies::EventStrategy; + +/// The `replaceable` arm. +#[derive(Debug, Clone, Copy, Default)] +pub struct ReplaceableStrategy; -/// Phase 2 stub — job 009; will upsert a replaceable event by its precedence rule; see `src/handlers/event-strategies/replaceable-event-strategy.ts`. -pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { - Err(RelayError::NotImplemented { - module: "relay::strategies::replaceable", - }) +impl EventStrategy for ReplaceableStrategy { + /// Phase 2 stub — job 009; will upsert a replaceable event by its precedence rule; see `src/handlers/event-strategies/replaceable-event-strategy.ts`. + fn execute<'a>( + &'a self, + _event: &'a Event, + _context: &'a HandlerContext, + ) -> Pin> + Send + 'a>> { + Box::pin(async { + Err(RelayError::NotImplemented { + module: "relay::strategies::replaceable", + }) + }) + } } diff --git a/crates/nostream-relay/src/strategies/report.rs b/crates/nostream-relay/src/strategies/report.rs index 195fdcc5d..4c1a9e538 100644 --- a/crates/nostream-relay/src/strategies/report.rs +++ b/crates/nostream-relay/src/strategies/report.rs @@ -1,13 +1,29 @@ //! The `report` event strategy. Mirrors `src/handlers/event-strategies/report-event-strategy.ts`. +use std::future::Future; +use std::pin::Pin; + use nostream_core::Event; use crate::error::RelayError; use crate::handlers::HandlerContext; +use crate::strategies::EventStrategy; + +/// The `report` arm. +#[derive(Debug, Clone, Copy, Default)] +pub struct ReportStrategy; -/// Phase 2 stub — job 009; will score and store a kind-1984 report (NIP-56); see `src/handlers/event-strategies/report-event-strategy.ts`. -pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { - Err(RelayError::NotImplemented { - module: "relay::strategies::report", - }) +impl EventStrategy for ReportStrategy { + /// Phase 2 stub — job 009; will score and store a kind-1984 report (NIP-56); see `src/handlers/event-strategies/report-event-strategy.ts`. + fn execute<'a>( + &'a self, + _event: &'a Event, + _context: &'a HandlerContext, + ) -> Pin> + Send + 'a>> { + Box::pin(async { + Err(RelayError::NotImplemented { + module: "relay::strategies::report", + }) + }) + } } diff --git a/crates/nostream-relay/src/strategies/timestamp.rs b/crates/nostream-relay/src/strategies/timestamp.rs index ff44cfc06..eb98f9da9 100644 --- a/crates/nostream-relay/src/strategies/timestamp.rs +++ b/crates/nostream-relay/src/strategies/timestamp.rs @@ -1,13 +1,29 @@ //! The `timestamp` event strategy. Mirrors `src/handlers/event-strategies/timestamp-event-strategy.ts`. +use std::future::Future; +use std::pin::Pin; + use nostream_core::Event; use crate::error::RelayError; use crate::handlers::HandlerContext; +use crate::strategies::EventStrategy; + +/// The `timestamp` arm. +#[derive(Debug, Clone, Copy, Default)] +pub struct TimestampStrategy; -/// Phase 2 stub — job 009; will validate the OpenTimestamps attestation (NIP-03); see `src/handlers/event-strategies/timestamp-event-strategy.ts`. -pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { - Err(RelayError::NotImplemented { - module: "relay::strategies::timestamp", - }) +impl EventStrategy for TimestampStrategy { + /// Phase 2 stub — job 009; will validate the OpenTimestamps attestation (NIP-03); see `src/handlers/event-strategies/timestamp-event-strategy.ts`. + fn execute<'a>( + &'a self, + _event: &'a Event, + _context: &'a HandlerContext, + ) -> Pin> + Send + 'a>> { + Box::pin(async { + Err(RelayError::NotImplemented { + module: "relay::strategies::timestamp", + }) + }) + } } diff --git a/crates/nostream-relay/src/strategies/vanish.rs b/crates/nostream-relay/src/strategies/vanish.rs index 884b5e61e..cbd97d7d2 100644 --- a/crates/nostream-relay/src/strategies/vanish.rs +++ b/crates/nostream-relay/src/strategies/vanish.rs @@ -1,13 +1,29 @@ //! The `vanish` event strategy. Mirrors `src/handlers/event-strategies/vanish-event-strategy.ts`. +use std::future::Future; +use std::pin::Pin; + use nostream_core::Event; use crate::error::RelayError; use crate::handlers::HandlerContext; +use crate::strategies::EventStrategy; + +/// The `vanish` arm. +#[derive(Debug, Clone, Copy, Default)] +pub struct VanishStrategy; -/// Phase 2 stub — job 009; will delete the requester's events and mark the pubkey vanished (NIP-62); see `src/handlers/event-strategies/vanish-event-strategy.ts`. -pub async fn execute(_event: &Event, _context: &HandlerContext) -> Result<(), RelayError> { - Err(RelayError::NotImplemented { - module: "relay::strategies::vanish", - }) +impl EventStrategy for VanishStrategy { + /// Phase 2 stub — job 009; will delete the requester's events and mark the pubkey vanished (NIP-62); see `src/handlers/event-strategies/vanish-event-strategy.ts`. + fn execute<'a>( + &'a self, + _event: &'a Event, + _context: &'a HandlerContext, + ) -> Pin> + Send + 'a>> { + Box::pin(async { + Err(RelayError::NotImplemented { + module: "relay::strategies::vanish", + }) + }) + } } diff --git a/crates/nostream-relay/src/transport.rs b/crates/nostream-relay/src/transport.rs index 1ef803248..a69725058 100644 --- a/crates/nostream-relay/src/transport.rs +++ b/crates/nostream-relay/src/transport.rs @@ -16,7 +16,6 @@ use tokio_tungstenite::tungstenite::Message; use nostream_config::settings::Settings; use tokio::net::TcpStream; -use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::tungstenite::protocol::CloseFrame; use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; @@ -92,14 +91,23 @@ pub fn protocol_error_frame() -> CloseFrame { } /// `getWsDrainTimeoutMs` (`src/utils/shutdown-state.ts:17-29`): unset, empty, non-numeric or -/// negative resolves to 30000; otherwise the parsed value in milliseconds. +/// negative resolves to 30000; otherwise the parsed value in milliseconds. The parse is the +/// JavaScript one, so `"10.5"` is 10.5 ms and truncates to 10, where an integer parse would +/// have fallen back to the default. pub fn resolve_drain_timeout(raw: Option<&str>) -> u64 { let Some(raw) = raw.filter(|value| !value.is_empty()) else { return DEFAULT_WS_DRAIN_TIMEOUT_MS; }; - match raw.parse::() { - Ok(millis) if millis >= 0 => u64::try_from(millis).unwrap_or(DEFAULT_WS_DRAIN_TIMEOUT_MS), + match raw.trim().parse::() { + Ok(millis) if millis.is_finite() && millis >= 0.0 => { + // `setTimeout` truncates a fractional delay to whole milliseconds. + if millis >= u64::MAX as f64 { + DEFAULT_WS_DRAIN_TIMEOUT_MS + } else { + millis.trunc() as u64 + } + } _ => DEFAULT_WS_DRAIN_TIMEOUT_MS, } } @@ -155,11 +163,14 @@ pub fn classify(message: Message) -> Inbound { } /// Writes one text frame. This is the relay's single write point for outbound JSON. +/// +/// The frame is taken by value: the encoder's `String` becomes the frame's payload without +/// a second copy of the whole message. pub async fn send_text( transport: &mut AcceptedTransport, - text: &str, + text: String, ) -> Result<(), tokio_tungstenite::tungstenite::Error> { - transport.send(Message::Text(text.to_owned().into())).await + transport.send(Message::Text(text.into())).await } /// Answers a client Ping in place. @@ -170,9 +181,6 @@ pub async fn send_pong( transport.send(Message::Pong(payload.into())).await } -/// A negotiated socket, for callers that hold the concrete type. -pub type Transport = WebSocketStream; - #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] @@ -212,5 +220,9 @@ mod tests { assert_eq!(resolve_drain_timeout(Some("-1")), 30_000); assert_eq!(resolve_drain_timeout(Some("10")), 10); assert_eq!(resolve_drain_timeout(Some("0")), 0); + // `Number("10.5")` is a number, so the capture arms a 10.5 ms timer and `setTimeout` + // truncates it; an integer parse would have rejected the value. + assert_eq!(resolve_drain_timeout(Some("10.5")), 10); + assert_eq!(resolve_drain_timeout(Some(" 25 ")), 25); } } diff --git a/crates/nostream-relay/tests/common/mod.rs b/crates/nostream-relay/tests/common/mod.rs index e54901f3e..632261156 100644 --- a/crates/nostream-relay/tests/common/mod.rs +++ b/crates/nostream-relay/tests/common/mod.rs @@ -52,13 +52,14 @@ impl Server { let address = listener.local_addr().expect("local addr"); let clock = Arc::new(TestClock::new(NOW_MS)); - let relay = Relay::with_keys( + let relay = Relay::new( (*fixture.settings).clone(), Arc::clone(&fixture.stores), clock.clone(), keys, - ) - .with_config(config); + Arc::new(nostream_core::random::SystemRandom), + config, + ); let shutdown = ShutdownState::new(); let connection_shutdown = shutdown.clone(); diff --git a/crates/nostream-storage/src/lib.rs b/crates/nostream-storage/src/lib.rs index ff1077a33..4b3b52025 100644 --- a/crates/nostream-storage/src/lib.rs +++ b/crates/nostream-storage/src/lib.rs @@ -9,7 +9,9 @@ //! - `src/database/client.ts`, `src/database/transaction.ts` (`src/postgres/mod.rs`) //! - the schema: `migrations/` (37 knex migrations, ported to `migrations/*.sql`) //! - `src/cache/client.ts`, `src/adapters/redis-adapter.ts` -//! - `src/utils/event-store.ts`, `src/utils/hidden-content-cache.ts` +//! - `src/utils/event-store.ts` (the mirror of `src/utils/hidden-content-cache.ts` is the +//! relay's: the cache polls the report repository from a settings-dependent task, so +//! `nostream-relay::hidden_content` owns it and this crate owns only the query it calls) //! - `src/@types/{adapters,cache,database,repositories,invite-code,invoice,nip05,report, //! notification-outbox,user}.ts` //! - `src/factories/event-repository-factory.ts` diff --git a/crates/nostreamd/src/acceptor.rs b/crates/nostreamd/src/acceptor.rs index 841e81487..dc0311e2e 100644 --- a/crates/nostreamd/src/acceptor.rs +++ b/crates/nostreamd/src/acceptor.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use tokio::net::TcpListener; use nostream_core::lifecycle::ShutdownState; -use nostream_relay::{Relay, RelayError}; +use nostream_relay::Relay; use crate::error::DaemonError; @@ -49,18 +49,13 @@ impl Acceptor { /// /// `Relay::serve` owns the accept loop so the socket never leaves the relay's hands: it /// upgrades each accepted stream, spawns its connection task and waits out the drain - /// timeout (`web-socket-server-adapter.ts:67-95`). - pub async fn run(self, relay: Arc, shutdown: ShutdownState) -> Result<(), DaemonError> { + /// timeout (`web-socket-server-adapter.ts:67-95`). The handover is recorded in ADR 0004 + /// (against spec R1's "the acceptor hands the accepted socket to the relay"): the drain + /// deadline and the `JoinSet` it waits on are one decision, so both live where the + /// sockets are served. + pub async fn run(self, relay: Arc, shutdown: ShutdownState) { tracing::debug!(at = %self.local_addr, "the relay takes the listener"); - relay - .serve(self.listener, shutdown) - .await - .map_err(|error| match error { - RelayError::Draining => { - DaemonError::Setup("the relay was already draining".to_owned()) - } - other => DaemonError::Setup(other.to_string()), - }) + relay.serve(self.listener, shutdown).await; } } diff --git a/crates/nostreamd/src/main.rs b/crates/nostreamd/src/main.rs index d283b8e2b..2cba71dbe 100644 --- a/crates/nostreamd/src/main.rs +++ b/crates/nostreamd/src/main.rs @@ -108,17 +108,19 @@ async fn serve( // The storage backend is not wired yet (the LMDB environment is a stub), so the relay // runs over seams that report `NotImplemented`; the wire itself is served in full. - let relay = Relay::with_keys( + let relay = Relay::new( settings, std::sync::Arc::new(unavailable::stores()), std::sync::Arc::new(SystemClock), RelayKeySource::from_env(), + std::sync::Arc::new(nostream_core::random::SystemRandom), + nostream_relay::relay::RelayConfig::default(), ); tracing::info!(at = %acceptor.local_addr(), "the acceptor is listening"); let connection_shutdown = shutdown.clone(); - let accept_task = tokio::spawn(async move { acceptor.run(relay, connection_shutdown).await }); + let accept_task = tokio::spawn(acceptor.run(relay, connection_shutdown)); let workers_task = tokio::spawn(async { nostreamd::workers::spawn_workers().await }); let tor_task = tokio::spawn(async { nostreamd::workers::spawn_tor().await }); @@ -128,8 +130,7 @@ async fn serve( shutdown.begin_draining(); let accept_result = accept_task .await - .map_err(|error| DaemonError::Setup(format!("acceptor task: {error}"))) - .and_then(std::convert::identity); + .map_err(|error| DaemonError::Setup(format!("acceptor task: {error}"))); let workers_result = workers_task .await .map_err(|error| DaemonError::Setup(format!("workers task: {error}"))) diff --git a/crates/nostreamd/tests/acceptor.rs b/crates/nostreamd/tests/acceptor.rs index 0822b6fc3..61ece357b 100644 --- a/crates/nostreamd/tests/acceptor.rs +++ b/crates/nostreamd/tests/acceptor.rs @@ -24,18 +24,22 @@ async fn the_acceptor_binds_an_ephemeral_port_and_drains_on_shutdown() { assert!(port > 0, "the ephemeral port resolved"); let fixture = Fixture::new(settings()); - let relay = Relay::with_keys( + let relay = Relay::new( (*fixture.settings).clone(), Arc::clone(&fixture.stores), Arc::new(TestClock::new(1_700_000_000_000)), RelayKeySource::from_values(None, None), + Arc::new(nostream_core::random::SystemRandom), + nostream_relay::relay::RelayConfig::default(), ); let shutdown = ShutdownState::new(); let connection_shutdown = shutdown.clone(); let task = tokio::spawn(async move { acceptor.run(relay, connection_shutdown).await }); - // A socket connects and the relay takes it; the wire answers with the NIP-42 challenge. + // A socket connects and the relay's accept loop takes it. This socket is dropped before + // the upgrade, so it proves the accept path was reached and the drain ends the loop; that + // the wire answers with the NIP-42 challenge is `tests/wire.rs`'s. let stream = tokio::net::TcpStream::connect(("127.0.0.1", port)) .await .expect("connect"); @@ -45,9 +49,10 @@ async fn the_acceptor_binds_an_ephemeral_port_and_drains_on_shutdown() { shutdown.begin_draining(); - let result = tokio::time::timeout(Duration::from_secs(2), task) + // The task ends within the deadline: serving reports no result, so the only thing left to + // get wrong is a drain that never completes. + tokio::time::timeout(Duration::from_secs(2), task) .await .expect("the acceptor stops within two seconds") .expect("join"); - assert!(result.is_ok(), "the acceptor drained cleanly"); } diff --git a/crates/nostreamd/tests/wire.rs b/crates/nostreamd/tests/wire.rs index 98dd57e26..d47a5466f 100644 --- a/crates/nostreamd/tests/wire.rs +++ b/crates/nostreamd/tests/wire.rs @@ -27,11 +27,13 @@ async fn start() -> (u16, ShutdownState) { let port = acceptor.local_addr().port(); let fixture = Fixture::new(settings()); - let relay = Relay::with_keys( + let relay = Relay::new( (*fixture.settings).clone(), Arc::clone(&fixture.stores), Arc::new(TestClock::new(1_700_000_000_000)), RelayKeySource::from_values(None, None), + Arc::new(nostream_core::random::SystemRandom), + nostream_relay::relay::RelayConfig::default(), ); let shutdown = ShutdownState::new(); diff --git a/docs/rewrite/decisions/0004-wire-transport.md b/docs/rewrite/decisions/0004-wire-transport.md index f3dc2b64f..8e114abff 100644 --- a/docs/rewrite/decisions/0004-wire-transport.md +++ b/docs/rewrite/decisions/0004-wire-transport.md @@ -88,26 +88,85 @@ unparseable JSON (V8's parse diagnostics are not reproducible; the prefix is), a **T2**, the reason text of a close frame whose code matches. The fixture declares T1 for the one malformed-JSON step and nothing else. -Two steps are recorded but not byte-compared, and the fixture says so per step: +Two steps are compared as JSON values rather than text, and the fixture says so per step +(`compare: "json"`, with a `compare_why`): - **A REQ result and the minted claim event.** The capture's in-memory event objects carry field insertion order into `JSON.stringify`, and its paths do not agree: a stored row is emitted as `id, kind, pubkey, created_at, content, tags, sig`, a broadcast of the stored event as `id, pubkey, created_at, kind, tags, content, sig`, and the invite-code builder as `pubkey, kind, created_at, content, tags, id, sig`. One - Rust serializer cannot produce three orders, so the relay emits the one NIP-01 order - every other EVENT frame matches and these two frames are compared structurally by the - handler tests instead. Everything else — the challenge, every NOTICE, OK, EOSE, - CLOSED and COUNT frame, the broadcast EVENT frames, the close codes and the drain — - is compared as text. + Rust serializer cannot produce three orders, and JSON has no field order on the wire, + so both sides are parsed and the values compared: the same keys, the same values, the + same array order. One difference inside a relay-minted event is accepted on top of + that — a signature depends on the auxiliary random the signer drew — and it is + accepted only when the observed signature verifies against the observed event, so the + frame's content is checked rather than skipped. Everything else — the challenge, every + NOTICE, OK, EOSE, CLOSED and COUNT frame, the broadcast EVENT frames, the close codes + and the drain — is compared as text. - **`no-secret`.** With no `SECRET` the capture logs the failure inside its rate-limit step and sends nothing; the Rust relay resolves the key at the same step and answers `["OK",,false,"error: unable to process event"]`, which is the behaviour - `handlers.rs` pins and AC29 asks for. The session is recorded, and its frames are not - compared. - -Both exceptions are visible in the fixture (`compare: false`, `comparison: "skipped"`) -with their reasons, and in the job's build report. + `handlers.rs` pins and AC29 asks for. The session is recorded (`comparison: + "skipped"`), and its frames are compared against the harness's own AC29 constant + rather than the recording, so the session is not unchecked. + +Both exceptions are counted: `the_not_byte_compared_steps_are_exactly_the_declared_two_and_one_session` +fails if another step or session joins them, and `a_corrupted_expected_frame_fails_the_replay` +shows that a garbled expectation fails in either mode. + +## Rate-limiter stores reclaim their keys + +The capture's two Lua scripts set a TTL on every key (`PEXPIRE key period`, +`EXPIRE key ceil(period / 1000)`), and the in-process stores carry the same lifetime: +a key is swept once every hit in it is older than the window it was written with, on a +clock-driven cadence so the sweep is amortised rather than paid per hit, and the map is +capped at `MAX_STORE_KEYS` (65,536) by dropping one arbitrary key, because the alternative +— refusing new keys — would let a client mint pubkeys to escape its own limit. + +## The accept loop lives in the relay, and serving cannot fail + +Spec R1 says the acceptor hands the accepted socket to the relay; this job hands the +*listener* over instead (`Acceptor::run` → `Relay::serve`), because the drain deadline and +the `JoinSet` the drain waits on are one decision, and a socket the relay must drain +cannot be owned by a loop that has already returned. `Relay::serve` therefore returns +nothing: an accept error is logged and the loop continues, and the drain is bounded by +`RelayConfig::drain_timeout`. `RelayError::Draining` and `RelayError::Transport` were +deleted with the `Result`: nothing constructed them. + +Every collaborator is a constructor argument (`Relay::new(settings, stores, clock, keys, +random, config)`) rather than a setter, so the randomness a fixture replays is the one the +relay was built with, and `WS_DRAIN_TIMEOUT_MS` is read where the relay is constructed — +`RelayConfig::default()` at the composition root — not inside the library. + +## A strategy is a type, and its signature is `(event, context)` + +The capture's `IEventStrategy.execute(event, sink)` becomes +`EventStrategy::execute(&self, event: &Event, context: &HandlerContext)`: a strategy +writes through the context's sink and needs the context's stores, settings and clock +(the default arm calls `EventRepository::create` and the broadcast hub, neither of which a +bare sink carries). Every arm — the filled `DefaultStrategy` and the fourteen stubs — is a +zero-sized type implementing the trait, so job 009 changes a body rather than choosing +between two shapes. + +## Two orders the capture leaves unspecified, fixed deliberately + +- **Subscriptions are walked in id order.** The capture's `Map` walks insertion order; the + port's `Connection` keeps a `BTreeMap`, so a drain or a fan-out emits `CLOSED`/`EVENT` + frames in id order. Observable only when a client's ids are not alphabetical, and the + fixture's ids are sorted. +- **The connection-level limit is keyed on the peer address.** The other three levels key + on `Connection::remote_address`, which is derived from the transport; the connection + level is decided before the socket is upgraded (a refused client must never see the + challenge), so it reads the accept loop's `peer` — the same address. + +## The hidden-content mirror is the relay's + +`src/utils/hidden-content-cache.ts` polls the report repository on an interval that +depends on the settings, so the mirror lives in `nostream-relay::hidden_content` and +`nostream-storage` owns only the query it calls. `Relay::serve` starts the refresh task +and aborts it when the loop ends; dropping the handle would detach the task, so the +handle's owner is the caller that started it. ## The census is a checked artifact From 80b82c256350551af66f1de21285a3a4c416b692 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Fri, 23 Oct 2026 18:02:20 -0400 Subject: [PATCH 13/20] test(relay): compare a step through one function, skipped session included --- crates/nostream-relay/tests/wire_parity.rs | 32 ++++++++++++---------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/crates/nostream-relay/tests/wire_parity.rs b/crates/nostream-relay/tests/wire_parity.rs index c2c0dc328..017bfc183 100644 --- a/crates/nostream-relay/tests/wire_parity.rs +++ b/crates/nostream-relay/tests/wire_parity.rs @@ -503,13 +503,9 @@ async fn replay(session: &Session, random: Arc) -> BTreeMap) -> BTreeMap usize { +/// How many frames a step is read for. A skipped session's recording is empty, so its count +/// comes from the harness's own expectation instead. +fn frame_count(session: &Session, step: &Step) -> usize { if session.skipped { AC29.len() } else { @@ -648,6 +640,16 @@ fn pinned_count(session: &Session, step: &Step) -> usize { } } +/// Compares one step's frames: against the recording, or against the harness's expectation +/// for the one session the capture does not record. +fn compare_step(session: &Session, step: &Step, replay: &Replay) { + if session.skipped { + compare_pinned(AC29, replay, step, &session.id); + } else { + replay.compare(&step.expect, step, &session.id); + } +} + /// Compares a skipped session's frames against the harness's own expectation (AC29), so the /// skip is a declared capture gap and not an unchecked session. fn compare_pinned(expected: &[&str], replay: &Replay, step: &Step, session: &str) { From 9daafbf1cd94006a75a35333bc2a5a4af04eddd1 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Fri, 23 Oct 2026 19:25:31 -0400 Subject: [PATCH 14/20] fix(relay): refuse a new rate-limit key instead of freeing a live one --- crates/nostream-relay/src/error.rs | 4 - crates/nostream-relay/src/handlers/event.rs | 4 +- .../nostream-relay/src/handlers/subscribe.rs | 7 + crates/nostream-relay/src/hidden_content.rs | 3 +- crates/nostream-relay/src/limits.rs | 163 ++++++++++------- crates/nostream-relay/src/relay.rs | 18 +- crates/nostream-relay/src/testing.rs | 46 ++++- crates/nostream-relay/src/transport.rs | 13 +- crates/nostream-relay/tests/common/mod.rs | 20 ++- crates/nostream-relay/tests/connection.rs | 140 +++++++++++++++ crates/nostream-relay/tests/limits.rs | 166 +++++++++++++++++- crates/nostream-relay/tests/wire_parity.rs | 8 +- crates/nostreamd/src/main.rs | 6 +- crates/nostreamd/tests/acceptor.rs | 6 +- crates/nostreamd/tests/wire.rs | 6 +- docs/rewrite/decisions/0004-wire-transport.md | 42 ++++- 16 files changed, 549 insertions(+), 103 deletions(-) diff --git a/crates/nostream-relay/src/error.rs b/crates/nostream-relay/src/error.rs index 3e1a1d776..669058a86 100644 --- a/crates/nostream-relay/src/error.rs +++ b/crates/nostream-relay/src/error.rs @@ -11,10 +11,6 @@ pub enum RelayError { #[error("not implemented: {module}")] NotImplemented { module: &'static str }, - /// A client message could not be decoded or is malformed. - #[error("invalid message: {0}")] - InvalidMessage(String), - /// A repository call failed. #[error(transparent)] Storage(#[from] StorageError), diff --git a/crates/nostream-relay/src/handlers/event.rs b/crates/nostream-relay/src/handlers/event.rs index 043295db4..01ce64c34 100644 --- a/crates/nostream-relay/src/handlers/event.rs +++ b/crates/nostream-relay/src/handlers/event.rs @@ -110,7 +110,7 @@ pub async fn handle(event: Event, context: &HandlerContext) { return; } - if let Some(reason) = is_protected_event_blocked(&event, context).await { + if let Some(reason) = is_protected_event_blocked(&event, context) { context.send_ok(event.id, false, &reason, None); return; } @@ -400,7 +400,7 @@ fn is_authentication_required( } /// `isProtectedEventBlocked` (`:299-331`). -async fn is_protected_event_blocked(event: &Event, context: &HandlerContext) -> Option { +fn is_protected_event_blocked(event: &Event, context: &HandlerContext) -> Option { if event.is_protected() && !context.authenticated_pubkeys().contains(&event.pubkey) { return Some("auth-required: this event may only be published by its author".to_owned()); } diff --git a/crates/nostream-relay/src/handlers/subscribe.rs b/crates/nostream-relay/src/handlers/subscribe.rs index 11d5581b9..ce5e2c210 100644 --- a/crates/nostream-relay/src/handlers/subscribe.rs +++ b/crates/nostream-relay/src/handlers/subscribe.rs @@ -251,6 +251,13 @@ async fn fetch_and_send(subscription_id: &str, filters: &[Filter], context: &Han // event"; it is asked per event, as the capture's // `createReadAuthorizationGuard` is, so an AUTH that lands mid-read applies to // the rest of the stream. + let g = context.read_authorization_guard(); + println!( + "judging kind {} now {} auth {:?}", + event.kind, + context.now_secs(), + g.allows(&event) + ); if expiration::is_expired_event(&event, now) || !context.read_authorization_guard().allows(&event) || !matches_any(&event) diff --git a/crates/nostream-relay/src/hidden_content.rs b/crates/nostream-relay/src/hidden_content.rs index 61fc4d12a..b2a54b41b 100644 --- a/crates/nostream-relay/src/hidden_content.rs +++ b/crates/nostream-relay/src/hidden_content.rs @@ -97,7 +97,8 @@ impl HiddenContentCache { .clone() } - /// How many refreshes have queried the repository. + /// How many refreshes have queried the repository. The parity harness polls this; it is + /// not a metric. pub fn refreshes(&self) -> u64 { *self .refreshes diff --git a/crates/nostream-relay/src/limits.rs b/crates/nostream-relay/src/limits.rs index 12f019063..403979677 100644 --- a/crates/nostream-relay/src/limits.rs +++ b/crates/nostream-relay/src/limits.rs @@ -68,12 +68,23 @@ struct SlidingEntry { step: u64, } -/// The most keys one store holds. Past this the store drops one arbitrary key to make room: -/// a client that mints pubkeys cannot grow the process without bound, and the cost — the -/// displaced key's window starts again — is smaller than the alternative, refusing new keys, -/// which would let that client evade its own limit by minting a fresh pubkey per event. +/// The most keys one store holds. +/// +/// A key that arrives when the store is full is **refused**, and its hit counts as limited: +/// displacement would let a client that mints keys free a live key — its own, or a third +/// party's — and a refusal yields `limited`, which is the outcome a flooding client is trying +/// to escape. Every level has a store of its own, so a flood at one level cannot fill +/// another's. pub const MAX_STORE_KEYS: usize = 65_536; +/// How often a store reclaims keys, as a floor on how often one pass may run. +/// +/// The cadence is the store's, never the caller's: armed from a caller's window, one hit on +/// an hour-long window would postpone reclaiming every one-second key for an hour. The pass +/// itself drops each entry against *its own* window, so one pass per interval is the whole +/// job, and the interval keeps the pass — which holds the store's mutex — off the hot path. +pub const SWEEP_INTERVAL_MS: u64 = 1_000; + /// A keyed store with the TTL the capture's Redis sets: expired keys are reclaimed, and the /// map is capped so no client-chosen key can grow it without bound. #[derive(Debug)] @@ -90,21 +101,26 @@ impl Store { } } - /// Reclaims what `is_live` rejects at most once per `cadence_ms`, and keeps room for - /// `key` at the ceiling. Amortised on the clock rather than counted per hit, so a busy - /// key does not pay for the whole map on every message. - fn prepare(&mut self, key: &str, now_ms: u64, cadence_ms: u64, is_live: impl Fn(&V) -> bool) { + /// Reclaims what has lapsed, then says whether `key` may be recorded. + /// + /// The sweep runs at most once per [`SWEEP_INTERVAL_MS`], however long the caller's + /// window is — so a store at its ceiling costs one pass per interval and not one per + /// refused hit, which a client that floods keys would otherwise buy with every message. + /// The pass is what prefers a lapsed key over a live one; a store still full after it + /// refuses the new key rather than displacing a live one, and `false` is the caller's cue + /// to answer the hit as limited. + fn admit(&mut self, key: &str, now_ms: u64, is_live: impl Fn(&V) -> bool + Copy) -> bool { if now_ms >= self.next_sweep_ms { - self.entries.retain(|_, value| is_live(value)); - self.next_sweep_ms = now_ms.saturating_add(cadence_ms.max(1)); + self.sweep(now_ms, is_live); } - if self.entries.len() >= MAX_STORE_KEYS - && !self.entries.contains_key(key) - && let Some(victim) = self.entries.keys().next().cloned() - { - self.entries.remove(&victim); - } + self.entries.contains_key(key) || self.entries.len() < MAX_STORE_KEYS + } + + /// One pass over the map, dropping every entry past its own window. + fn sweep(&mut self, now_ms: u64, is_live: impl Fn(&V) -> bool) { + self.entries.retain(|_, value| is_live(value)); + self.next_sweep_ms = now_ms.saturating_add(SWEEP_INTERVAL_MS); } } @@ -141,16 +157,42 @@ impl SlidingWindowRateLimiter { let window_start = now.saturating_sub(config.period_ms); let mut store = self.store()?; - // The key lives one window from its last write, which is the `PEXPIRE` the script - // sets: once every hit is older than that window the key is reclaimed rather than - // pruned on its next visit. - store.prepare(key, now, config.period_ms, |sliding| { + // A key lives one window from its last write, which is the `PEXPIRE` the script sets. + if !store.admit(key, now, |sliding| { sliding .entries .iter() .any(|entry| now.saturating_sub(entry.at_ms) < sliding.period_ms) + }) { + return Ok(true); + } + + if let Some(sliding) = store.entries.get_mut(key) { + // The key's `PEXPIRE` was set at its last write: once every hit is older than + // that window, Redis has dropped the key and this hit starts from an empty set. + if sliding + .entries + .iter() + .all(|entry| now.saturating_sub(entry.at_ms) >= sliding.period_ms) + { + sliding.entries.clear(); + } + + sliding.entries.retain(|entry| entry.at_ms > window_start); + } + + let sum = store.entries.get(key).map_or(0, |sliding| { + sliding + .entries + .iter() + .fold(0u64, |total, entry| total.saturating_add(entry.step)) }); + if sum.saturating_add(step) > config.rate { + // The script `ZADD`s only on the allow path, so a refused hit creates no key. + return Ok(true); + } + let sliding = store .entries .entry(key.to_owned()) @@ -158,27 +200,6 @@ impl SlidingWindowRateLimiter { period_ms: config.period_ms, entries: Vec::new(), }); - // The key's `PEXPIRE` was set at its last write: once every hit is older than that - // window, Redis has dropped the key and this hit starts from an empty set. - if sliding - .entries - .iter() - .all(|entry| now.saturating_sub(entry.at_ms) >= sliding.period_ms) - { - sliding.entries.clear(); - } - - sliding.entries.retain(|entry| entry.at_ms > window_start); - - let sum = sliding - .entries - .iter() - .fold(0u64, |total, entry| total.saturating_add(entry.step)); - - if sum.saturating_add(step) > config.rate { - return Ok(true); - } - sliding.period_ms = config.period_ms; sliding.entries.push(SlidingEntry { at_ms: now, step }); @@ -255,9 +276,12 @@ impl EwmaRateLimiter { .store .lock() .map_err(|error| RelayError::RateLimiter(format!("ewma store: {error}")))?; - store.prepare(key, now, ttl_ms, |entry| { + if !store.admit(key, now, |entry| { now.saturating_sub(entry.at_ms) < entry.ttl_ms - }); + }) { + // A full store of live keys refuses the new one, and the refusal is the verdict. + return Ok(true); + } // The key's TTL is `ceil(period / 1000)` seconds from its last write: at that // instant Redis has dropped it, so the entry is gone too. @@ -387,10 +411,17 @@ impl Limiter for RateLimiter { } } -/// The four wired levels (connection, message, event, invite) over one limiter. +/// The four wired levels (connection, message, event, invite), each over a store of its own. +/// +/// The capture reaches one Redis singleton, whose keys expire and whose memory is the +/// server's; here the map is bounded, so one store per level keeps a flood at one level from +/// filling another's and refusing its clients. #[derive(Debug)] pub struct RateLimits { - limiter: Arc, + connection_limiter: Arc, + message_limiter: Arc, + event_limiter: Arc, + invite_limiter: Arc, connection: Option, message: Option, event: Option, @@ -398,22 +429,36 @@ pub struct RateLimits { } impl RateLimits { - /// Reads every level from settings. The limiter is shared by all four, as the - /// capture's `rateLimiterFactory` singleton is. + /// Reads every level from settings, giving each level its own store of the strategy the + /// settings select. pub fn new(settings: &Settings, clock: Arc) -> Self { - Self::with_limiter( - Arc::new(RateLimiter::from_settings(settings, clock)), - settings, - ) + let store = || { + Arc::new(RateLimiter::from_settings(settings, Arc::clone(&clock))) as Arc + }; + let limits = settings.limits.as_ref(); + + Self { + connection_limiter: store(), + message_limiter: store(), + event_limiter: store(), + invite_limiter: store(), + connection: limits.and_then(|limits| limits.connection.clone()), + message: limits.and_then(|limits| limits.message.clone()), + event: limits.and_then(|limits| limits.event.clone()), + invite: limits.and_then(|limits| limits.invite.clone()), + } } - /// The four levels over an explicit limiter, which is how the store's failure path is - /// exercised. + /// The four levels over one explicit limiter, which is how the store's failure path is + /// exercised: every level shares the caller's limiter, so a failing store fails them all. pub fn with_limiter(limiter: Arc, settings: &Settings) -> Self { let limits = settings.limits.as_ref(); Self { - limiter, + connection_limiter: Arc::clone(&limiter), + message_limiter: Arc::clone(&limiter), + event_limiter: Arc::clone(&limiter), + invite_limiter: limiter, connection: limits.and_then(|limits| limits.connection.clone()), message: limits.and_then(|limits| limits.message.clone()), event: limits.and_then(|limits| limits.event.clone()), @@ -423,7 +468,7 @@ impl RateLimits { /// The store the settings selected. pub fn strategy(&self) -> RateLimiterStrategy { - self.limiter.strategy() + self.event_limiter.strategy() } /// `limits.connection.rateLimits` with its `ipWhitelist` bypass @@ -448,7 +493,7 @@ impl RateLimits { let config = RateLimitConfig::from(window); let key = format!("{address}:connection:{}", config.period_ms); - if self.limiter.hit(&key, 1, &config)? { + if self.connection_limiter.hit(&key, 1, &config)? { limited = true; } } @@ -484,7 +529,7 @@ impl RateLimits { let config = RateLimitConfig::from(window); let key = format!("{address}:message:{}", config.period_ms); - if self.limiter.hit(&key, 1, &config)? { + if self.message_limiter.hit(&key, 1, &config)? { return Ok(Some(backoff_ms(&config))); } } @@ -556,7 +601,7 @@ impl RateLimits { None => format!("{}:events:{}", event.pubkey, config.period_ms), }; - if self.limiter.hit(&key, 1, &config)? { + if self.event_limiter.hit(&key, 1, &config)? { return Ok(Some(backoff_ms(&config))); } } @@ -583,7 +628,7 @@ impl RateLimits { let config = RateLimitConfig::from(window); let key = format!("{pubkey}:invites:{}", config.period_ms); - if self.limiter.hit(&key, 1, &config)? { + if self.invite_limiter.hit(&key, 1, &config)? { return Ok(true); } } diff --git a/crates/nostream-relay/src/relay.rs b/crates/nostream-relay/src/relay.rs index 2810d7cf1..c2db2c52b 100644 --- a/crates/nostream-relay/src/relay.rs +++ b/crates/nostream-relay/src/relay.rs @@ -46,11 +46,13 @@ pub struct RelayConfig { } impl Default for RelayConfig { + /// The capture's own constants, and no environment read: `WS_DRAIN_TIMEOUT_MS` is + /// resolved by [`transport::drain_timeout`], which the composition root calls (ADR 0004). fn default() -> Self { Self { liveness_interval: Duration::from_millis(120_000), hidden_refresh_interval: DEFAULT_REFRESH_INTERVAL, - drain_timeout: transport::drain_timeout(), + drain_timeout: Duration::from_millis(transport::DEFAULT_WS_DRAIN_TIMEOUT_MS), } } } @@ -85,8 +87,8 @@ impl Relay { /// /// Everything the relay runs with is an argument, so a relay that mints from /// `SystemRandom` or resolves `WS_DRAIN_TIMEOUT_MS` from the environment is one whose - /// composition root asked for it (`RelayConfig::default()` reads the variable, so the - /// read happens where the relay is built, not inside a setter that can silently no-op). + /// composition root asked for it: `RelayConfig::default()` is the capture's constants, + /// and the environment read is [`transport::drain_timeout`] at the call site. pub fn new( settings: Settings, stores: Arc, @@ -112,11 +114,6 @@ impl Relay { }) } - /// The intervals this relay runs with. - pub fn config(&self) -> &RelayConfig { - &self.config - } - /// The hidden-content mirror. The parity harness reads it to wait for the boot refresh /// that seeds it; nothing else in the workspace calls this. pub fn hidden_content(&self) -> &Arc { @@ -237,8 +234,9 @@ impl Relay { /// window terminates the upgrade. /// /// Keyed on the accept loop's peer rather than on `Connection::remote_address`: the - /// decision is taken before the socket is upgraded, so there is no connection yet, and - /// both derive from the same socket address (ADR 0004). + /// decision is taken after the upgrade but before the connection is registered, as the + /// capture checks it after the handshake, so there is no `Connection` yet; both derive + /// from the same socket address (ADR 0004). async fn is_connection_rate_limited(&self, peer: &std::net::SocketAddr) -> bool { match self.limits.connection_limited(&peer.ip().to_string()) { Ok(limited) => limited, diff --git a/crates/nostream-relay/src/testing.rs b/crates/nostream-relay/src/testing.rs index 2bc5450d9..af2192671 100644 --- a/crates/nostream-relay/src/testing.rs +++ b/crates/nostream-relay/src/testing.rs @@ -141,6 +141,7 @@ pub struct InMemoryEventRepository { /// When set, `find_by_filters` holds its stream until a permit is released, which is how /// a test gets a read in flight long enough to `CLOSE` it. read_gate: Mutex>>, + gate_per_item: std::sync::atomic::AtomicBool, } impl InMemoryEventRepository { @@ -187,12 +188,25 @@ impl InMemoryEventRepository { /// Makes every read wait for a permit. Grant one with `Semaphore::add_permits` to let a /// read through. pub fn gate_reads(&self) -> Arc { + self.install_gate(false) + } + + /// Makes a read wait for one permit **per record**, so a test can let one record through, + /// change the connection's state, and let the next one through — the only way to observe + /// a decision taken per event rather than once per read. + pub fn gate_reads_per_item(&self) -> Arc { + self.install_gate(true) + } + + fn install_gate(&self, per_item: bool) -> Arc { let gate = Arc::new(tokio::sync::Semaphore::new(0)); *self .read_gate .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(Arc::clone(&gate)); + self.gate_per_item + .store(per_item, std::sync::atomic::Ordering::SeqCst); gate } @@ -264,6 +278,7 @@ impl EventRepository for InMemoryEventRepository { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .clone(); + let per_item = self.gate_per_item.load(std::sync::atomic::Ordering::SeqCst); Box::pin(async move { let records = self @@ -282,6 +297,33 @@ impl EventRepository for InMemoryEventRepository { return Ok(Box::pin(stream::iter(matched)) as QueryStream<'a, EventRecord>); }; + if per_item { + // One permit per record, and the permits are held rather than returned, so + // one release lets exactly one record through. + let held = Arc::new(Mutex::new(Vec::new())); + let gated = stream::iter(matched).then(move |item| { + let gate = Arc::clone(&gate); + let held = Arc::clone(&held); + + async move { + match gate.acquire_owned().await { + Ok(permit) => { + held.lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(permit); + + item + } + Err(_) => Err(StorageError::InvalidArgument { + message: "in-memory double: the read gate was closed", + }), + } + } + }); + + return Ok(Box::pin(gated) as QueryStream<'a, EventRecord>); + } + let permit = gate .acquire_owned() .await @@ -289,8 +331,8 @@ impl EventRepository for InMemoryEventRepository { message: "in-memory double: the read gate was closed", })?; - // The permit is held by the stream, so the read stays gated until it completes - // or the handler drops it. + // The permit is held by the stream, so the read stays gated until it completes or + // the handler drops it. let gated = stream::iter(matched).map(move |item| { let _held = &permit; diff --git a/crates/nostream-relay/src/transport.rs b/crates/nostream-relay/src/transport.rs index a69725058..3f11440be 100644 --- a/crates/nostream-relay/src/transport.rs +++ b/crates/nostream-relay/src/transport.rs @@ -112,14 +112,13 @@ pub fn resolve_drain_timeout(raw: Option<&str>) -> u64 { } } -/// `resolve_drain_timeout` over `WS_DRAIN_TIMEOUT_MS`. -pub fn drain_timeout_from_env() -> u64 { - resolve_drain_timeout(std::env::var("WS_DRAIN_TIMEOUT_MS").ok().as_deref()) -} - -/// The drain timeout as a duration. +/// The drain timeout as a duration: `WS_DRAIN_TIMEOUT_MS` through +/// [`resolve_drain_timeout`]. The composition root calls this; the library's defaults do not, +/// so a `RelayConfig::default()` never reads the environment. pub fn drain_timeout() -> Duration { - Duration::from_millis(drain_timeout_from_env()) + Duration::from_millis(resolve_drain_timeout( + std::env::var("WS_DRAIN_TIMEOUT_MS").ok().as_deref(), + )) } /// Whether a websocket error is the max-payload failure (measured: close code 1009). diff --git a/crates/nostream-relay/tests/common/mod.rs b/crates/nostream-relay/tests/common/mod.rs index 632261156..8ea38a985 100644 --- a/crates/nostream-relay/tests/common/mod.rs +++ b/crates/nostream-relay/tests/common/mod.rs @@ -46,8 +46,24 @@ impl Server { .await } - /// Starts a relay with an explicit key source and intervals. + /// Starts a relay with an explicit key source and intervals, minting from `SystemRandom`. pub async fn start_with(fixture: Fixture, keys: RelayKeySource, config: RelayConfig) -> Self { + Self::start_with_random( + fixture, + keys, + config, + Arc::new(nostream_core::random::SystemRandom), + ) + .await + } + + /// Starts a relay with every collaborator named, the randomness included. + pub async fn start_with_random( + fixture: Fixture, + keys: RelayKeySource, + config: RelayConfig, + random: Arc, + ) -> Self { let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let address = listener.local_addr().expect("local addr"); let clock = Arc::new(TestClock::new(NOW_MS)); @@ -57,7 +73,7 @@ impl Server { Arc::clone(&fixture.stores), clock.clone(), keys, - Arc::new(nostream_core::random::SystemRandom), + random, config, ); diff --git a/crates/nostream-relay/tests/connection.rs b/crates/nostream-relay/tests/connection.rs index a987e2704..07c117731 100644 --- a/crates/nostream-relay/tests/connection.rs +++ b/crates/nostream-relay/tests/connection.rs @@ -431,3 +431,143 @@ async fn closing_the_socket_aborts_an_in_flight_read() { ); assert_eq!(connection.subscription_count(), 0); } + +/// Starts a read that streams a public and then a restricted event, with the AUTH recorded +/// between the two when `authenticate_mid_read` is set, and returns the frames it produced. +/// +/// The gate releases one record at a time, so the AUTH lands after the first record has been +/// judged and before the second one is. +async fn restricted_read_with(authenticate_mid_read: bool) -> Vec { + let mut configured = settings(); + let mut nip42 = nostream_relay::testing::nip42_settings(); + // A session that lasts: the settings fixture expires one at once, which is what most + // AUTH tests want and this one does not. + nip42.session_expiry_seconds = Some(3_600); + configured.nip42 = Some(nip42); + + let fixture = Fixture::new(configured); + let gate = fixture.events.gate_reads_per_item(); + fixture + .seed_event(signed_event(&CLIENT_SECRET, 1, vec![], "public", NOW_SECS)) + .await; + fixture + .seed_event(signed_event( + &CLIENT_SECRET, + 4, + vec![], + "encrypted", + NOW_SECS, + )) + .await; + + let (sink, mut frames) = FrameSink::channel(); + // The session outlives the read: `Nip42Session` carries the TTL it was built with. + let connection = Arc::new(Connection::new( + "127.0.0.1".to_owned(), + nostream_core::nip42::Nip42Session::with_challenge("challenge", 3_600), + )); + let context = Arc::new(fixture.context( + Arc::new(TestClock::new(NOW_MS)), + relay_keys(), + Arc::clone(&connection), + sink, + )); + + let reading = tokio::spawn({ + let context = Arc::clone(&context); + + async move { + nostream_relay::handlers::subscribe::handle( + SubscribeMessage { + subscription_id: "restricted".to_owned(), + // A filter that names no kind is accepted while unauthenticated; the + // stream can still carry a restricted event, which the per-event guard is + // what withholds. + filters: vec![Filter::default()], + }, + &context, + ) + .await; + } + }); + + for _ in 0..100 { + if connection.read_in_flight("restricted") { + break; + } + + tokio::task::yield_now().await; + } + + assert!( + connection.read_in_flight("restricted"), + "the read is in flight" + ); + + // The public record passes while the client is unauthenticated. + gate.add_permits(1); + + let mut collected = Vec::new(); + for _ in 0..100 { + if let Ok(frame) = frames.try_recv() { + collected.push(frame); + break; + } + + tokio::task::yield_now().await; + } + + assert_eq!( + collected.len(), + 1, + "the public record arrived: {collected:?}" + ); + + // The AUTH lands between the two records: the guard the read uses next is built after it, + // as the capture's is (`createReadAuthorizationGuard(settings, () => getAuthenticatedPubkeys())`). + if authenticate_mid_read { + let now = i64::try_from(NOW_MS / 1_000).expect("seconds"); + let pubkey = nostream_core::crypto::public_key(&CLIENT_SECRET).expect("a pubkey"); + + assert!( + connection.authenticate( + pubkey, + nostream_core::event::EventId::from_byte_array([1; 32]), + now, + ), + "the AUTH is recorded" + ); + } + + gate.add_permits(1); + reading.await.expect("the read finishes"); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + + while let Ok(frame) = frames.try_recv() { + collected.push(frame); + } + + collected +} + +#[tokio::test] +async fn a_restricted_read_is_judged_against_the_authenticated_set_at_each_event() { + // No AUTH: the public record arrives and the restricted one is withheld. + let withheld = restricted_read_with(false).await; + + assert_eq!(withheld.len(), 2, "{withheld:?}"); + assert!(withheld[0].starts_with(r#"["EVENT","restricted","#)); + assert!(withheld[0].contains("public")); + assert_eq!(withheld[1], r#"["EOSE","restricted"]"#); + + // AUTH between the two records: the second one is delivered, which a guard built once per + // read could not do. + let authenticated = restricted_read_with(true).await; + + assert_eq!(authenticated.len(), 3, "{authenticated:?}"); + assert!(authenticated[0].starts_with(r#"["EVENT","restricted","#)); + assert!(authenticated[0].contains("public")); + assert!(authenticated[1].starts_with(r#"["EVENT","restricted","#)); + assert!(authenticated[1].contains("encrypted")); + assert_eq!(authenticated[2], r#"["EOSE","restricted"]"#); +} diff --git a/crates/nostream-relay/tests/limits.rs b/crates/nostream-relay/tests/limits.rs index 97af7f511..28ac9407d 100644 --- a/crates/nostream-relay/tests/limits.rs +++ b/crates/nostream-relay/tests/limits.rs @@ -97,6 +97,119 @@ fn a_refused_sliding_hit_is_not_recorded() { assert!(!hit(&limiter, "k", 1, &config)); assert!(hit(&limiter, "k", 1, &config)); assert_eq!(limiter.len("k"), 1, "the refused hit left no entry"); + + // `len` reads 0 for an absent key and for an empty one alike, so the key's existence is + // what the script's "without recording" forbids: `ZADD` runs only on the allow path. + let before = limiter.key_count(); + assert!(hit(&limiter, "refused-on-arrival", 2, &config)); + assert_eq!(limiter.key_count(), before, "a refused hit created a key"); +} + +#[test] +fn a_hit_on_a_long_window_does_not_postpone_reclaiming_a_short_one() { + let (clock, limiter) = sliding(BASE); + let short = RateLimitConfig { + period_ms: 1_000, + rate: 5, + }; + let long = RateLimitConfig { + period_ms: 3_600_000, + rate: 5, + }; + + // The long window's hit is the first one after the previous pass. On a cadence taken from + // the caller's window, the next pass would be an hour away and every one-second key in + // between would accumulate. + assert!(!hit(&limiter, "hour", 1, &long)); + + for index in 0..1_000 { + assert!(!hit(&limiter, &format!("k{index}"), 1, &short)); + } + + assert_eq!(limiter.key_count(), 1_001); + + // Two seconds later every one-second key has lapsed. The next hit is the pass, and it + // drops them because the cadence is the store's, not the long window's. + clock.advance(2_000); + assert!(!hit(&limiter, "another", 1, &short)); + assert_eq!( + limiter.key_count(), + 2, + "the lapsed one-second keys were reclaimed" + ); +} + +#[test] +fn one_level_s_flood_cannot_refuse_another_level_s_clients() { + // The levels have a store each: a message-level flood fills the message store and must + // leave the event level's keys alone (round 2's live reproduction). + let clock = Arc::new(TestClock::new(BASE)); + let mut settings = settings(); + settings.limits = Some(Limits { + message: Some(MessageLimits { + rate_limits: Some(vec![MessageRateLimit { + description: None, + message_type: None, + period: 3_600_000, + rate: 1_000_000, + }]), + ip_whitelist: None, + }), + event: Some(EventLimits { + rate_limits: Some(vec![EventRateLimit { + description: None, + kinds: None, + period: 3_600_000, + rate: 1, + }]), + ..Default::default() + }), + ..Default::default() + }); + let limits = RateLimits::new(&settings, clock); + + let event = signed_event(&CLIENT_SECRET, 1, vec![], "flood", NOW_SECS); + + assert!( + limits + .event_backoff(&event, "10.0.0.1", None) + .expect("a verdict") + .is_none(), + "the first event is inside the window" + ); + assert!( + limits + .event_backoff(&event, "10.0.0.1", None) + .is_ok_and(|backoff| backoff.is_some()), + "the event window is spent" + ); + + // Fill the message store past its ceiling with distinct addresses. + for index in 0..(2 * MAX_STORE_KEYS) { + limits + .message_backoff(&format!("10.1.{}.{}", index / 256, index % 256)) + .expect("a verdict"); + } + + assert!( + limits + .event_backoff(&event, "10.0.0.1", None) + .is_ok_and(|backoff| backoff.is_some()), + "the message flood reset the event window" + ); + + // And a client that has never published is still served. On one shared store the flood + // has filled it and this author's first event is refused; with a store per level it is + // not. + let fresh = signed_event(&[8u8; 32], 1, vec![], "fresh", NOW_SECS); + + assert!( + limits + .event_backoff(&fresh, "10.0.0.2", None) + .expect("a verdict") + .is_none(), + "a message flood refused a new author's first event" + ); } #[test] @@ -267,15 +380,64 @@ fn an_expired_ewma_key_is_reclaimed_instead_of_kept_for_the_process_lifetime() { fn a_flood_of_new_keys_never_grows_a_store_past_its_ceiling() { let (_, limiter) = sliding(BASE); let config = RateLimitConfig { - period_ms: 60_000, + period_ms: 1_000_000, rate: 3, }; - for index in 0..(MAX_STORE_KEYS + 1_000) { + for index in 0..MAX_STORE_KEYS { assert!(!hit(&limiter, &format!("k{index}"), 1, &config)); } + // Past the ceiling every new key is refused, and the refusal is the verdict: a client + // cannot make room by minting keys, and it cannot reach the store by pretending the + // refusal did not happen. + for index in MAX_STORE_KEYS..(MAX_STORE_KEYS + 1_000) { + assert!(hit(&limiter, &format!("k{index}"), 1, &config)); + } + assert_eq!(limiter.key_count(), MAX_STORE_KEYS); + assert_eq!(limiter.len(&format!("k{}", MAX_STORE_KEYS + 1)), 0); +} + +#[test] +fn a_full_store_of_live_keys_still_limits_the_keys_it_already_holds() { + // The security finding: displacement used to free a live key, so a flood reset a + // third party's exhausted window. A refused new key must leave every existing key alone. + let (_, limiter) = sliding(BASE); + let config = RateLimitConfig { + period_ms: 1_000_000, + rate: 1, + }; + + assert!(!hit(&limiter, "victim", 1, &config)); + assert!(hit(&limiter, "victim", 1, &config), "the victim is limited"); + + for index in 0..(MAX_STORE_KEYS * 2) { + hit(&limiter, &format!("stranger{index}"), 1, &config); + } + + assert!( + hit(&limiter, "victim", 1, &config), + "a stranger's flood reset the victim's window" + ); +} + +#[test] +fn a_lapsed_key_makes_room_before_a_new_key_is_refused() { + let (clock, limiter) = sliding(BASE); + let config = RateLimitConfig { + period_ms: 1_000, + rate: 3, + }; + + for index in 0..MAX_STORE_KEYS { + assert!(!hit(&limiter, &format!("k{index}"), 1, &config)); + } + + // Everything has lapsed: the next new key sweeps the map rather than being refused. + clock.advance(config.period_ms + 1); + assert!(!hit(&limiter, "fresh", 1, &config)); + assert_eq!(limiter.key_count(), 1); } #[test] diff --git a/crates/nostream-relay/tests/wire_parity.rs b/crates/nostream-relay/tests/wire_parity.rs index 017bfc183..03fc642c2 100644 --- a/crates/nostream-relay/tests/wire_parity.rs +++ b/crates/nostream-relay/tests/wire_parity.rs @@ -914,17 +914,19 @@ fn find(haystack: &[u8], needle: &[u8]) -> Option { #[tokio::test(flavor = "multi_thread")] async fn every_recorded_session_replays_frame_for_frame() { let fixture = fixture(); - let seed = fixture["seed"].as_u64().expect("a seed"); assert_eq!( fixture["clock"]["start_ms"].as_u64(), Some(1_700_000_000_000), "the recording declares its frozen clock" ); + assert!( + fixture["seed"].as_u64().is_some(), + "the recording declares the seed its generator's entropy came from" + ); // Each session carries the bytes its relay minted; a fresh source per session replays them, // so the fixture does not depend on a generator's position in a shared stream. - let _ = seed; let sessions: Vec = fixture["sessions"] .as_array() .expect("sessions") @@ -981,7 +983,7 @@ async fn every_recorded_session_replays_frame_for_frame() { session.id, counts.get("steps").copied().unwrap_or(0), if session.skipped { - " (declared not compared)" + " (compared against the harness's AC29 expectation)" } else { "" } diff --git a/crates/nostreamd/src/main.rs b/crates/nostreamd/src/main.rs index 2cba71dbe..b3a9b2bbd 100644 --- a/crates/nostreamd/src/main.rs +++ b/crates/nostreamd/src/main.rs @@ -114,7 +114,11 @@ async fn serve( std::sync::Arc::new(SystemClock), RelayKeySource::from_env(), std::sync::Arc::new(nostream_core::random::SystemRandom), - nostream_relay::relay::RelayConfig::default(), + // The one environment read: the drain deadline is the operator's. + nostream_relay::relay::RelayConfig { + drain_timeout: nostream_relay::transport::drain_timeout(), + ..Default::default() + }, ); tracing::info!(at = %acceptor.local_addr(), "the acceptor is listening"); diff --git a/crates/nostreamd/tests/acceptor.rs b/crates/nostreamd/tests/acceptor.rs index 61ece357b..f43eed6cc 100644 --- a/crates/nostreamd/tests/acceptor.rs +++ b/crates/nostreamd/tests/acceptor.rs @@ -30,7 +30,11 @@ async fn the_acceptor_binds_an_ephemeral_port_and_drains_on_shutdown() { Arc::new(TestClock::new(1_700_000_000_000)), RelayKeySource::from_values(None, None), Arc::new(nostream_core::random::SystemRandom), - nostream_relay::relay::RelayConfig::default(), + // Explicit, so the suite's drain deadline is its own and not the environment's. + nostream_relay::relay::RelayConfig { + drain_timeout: std::time::Duration::from_millis(300), + ..Default::default() + }, ); let shutdown = ShutdownState::new(); diff --git a/crates/nostreamd/tests/wire.rs b/crates/nostreamd/tests/wire.rs index d47a5466f..f2f080919 100644 --- a/crates/nostreamd/tests/wire.rs +++ b/crates/nostreamd/tests/wire.rs @@ -33,7 +33,11 @@ async fn start() -> (u16, ShutdownState) { Arc::new(TestClock::new(1_700_000_000_000)), RelayKeySource::from_values(None, None), Arc::new(nostream_core::random::SystemRandom), - nostream_relay::relay::RelayConfig::default(), + // Explicit, so the suite's drain deadline is its own and not the environment's. + nostream_relay::relay::RelayConfig { + drain_timeout: std::time::Duration::from_millis(300), + ..Default::default() + }, ); let shutdown = ShutdownState::new(); diff --git a/docs/rewrite/decisions/0004-wire-transport.md b/docs/rewrite/decisions/0004-wire-transport.md index 8e114abff..2dfba9a3d 100644 --- a/docs/rewrite/decisions/0004-wire-transport.md +++ b/docs/rewrite/decisions/0004-wire-transport.md @@ -115,14 +115,38 @@ Both exceptions are counted: `the_not_byte_compared_steps_are_exactly_the_declar fails if another step or session joins them, and `a_corrupted_expected_frame_fails_the_replay` shows that a garbled expectation fails in either mode. -## Rate-limiter stores reclaim their keys +## Rate-limiter stores reclaim their keys, and refuse rather than displace The capture's two Lua scripts set a TTL on every key (`PEXPIRE key period`, -`EXPIRE key ceil(period / 1000)`), and the in-process stores carry the same lifetime: -a key is swept once every hit in it is older than the window it was written with, on a -clock-driven cadence so the sweep is amortised rather than paid per hit, and the map is -capped at `MAX_STORE_KEYS` (65,536) by dropping one arbitrary key, because the alternative -— refusing new keys — would let a client mint pubkeys to escape its own limit. +`EXPIRE key ceil(period / 1000)`), and the in-process stores carry the same lifetime: one +`retain` pass drops every entry past **its own** window. The pass runs at most once per +`SWEEP_INTERVAL_MS` (one second), a cadence that belongs to the store — never to the +caller's window, or one hit on an hour-long window would postpone reclaiming every +one-second key for an hour — and that keeps a pass costing 1.3–2.0 ms at the ceiling off +the hot path. + +Each of the four levels has a store of its own, so a flood of message keys cannot fill the +store the event level uses. + +The map is capped at `MAX_STORE_KEYS` (65,536) per store, and a key that arrives when the +store is full and nothing has lapsed is **refused**, with its hit answered as limited. The +earlier policy — displace an arbitrary key — was wrong: a client that mints keys could free +a live key, its own or a third party's, and reset an exhausted window (measured on this +branch: a 240-per-hour message limit reset by another client's 80,000-event flood). +Refusal yields `limited`, which is precisely the outcome a flooding client is trying to +escape, so it is strictly safer; the cost is that a brand-new key is limited while the +store is full. + +Two limits worth recording rather than discovering in production: + +- **The ceiling bounds keys, not bytes.** A sliding key holds one entry per hit, so the + shipped 60-per-minute windows cap a key at 60 entries and the hour window at 720: 65,536 + keys × 720 entries is about 1 GB, reachable only at ~47 million accepted events. +- **A relay with no signing key does not rate-limit EVENT at all.** The event key is + resolved before the limiter (the capture's `isRateLimited` calls `getRelayPublicKey()`, + which throws), so with neither `SECRET` nor `RELAY_PRIVATE_KEY` set every event is + answered `error: unable to process event` — AC29's declared outcome — and no window is + consulted. `nostreamd` logs it; a deployment that wants the limiter must set a key. ## The accept loop lives in the relay, and serving cannot fail @@ -136,8 +160,10 @@ deleted with the `Result`: nothing constructed them. Every collaborator is a constructor argument (`Relay::new(settings, stores, clock, keys, random, config)`) rather than a setter, so the randomness a fixture replays is the one the -relay was built with, and `WS_DRAIN_TIMEOUT_MS` is read where the relay is constructed — -`RelayConfig::default()` at the composition root — not inside the library. +relay was built with. `RelayConfig::default()` is the capture's own constants and reads no +environment: `WS_DRAIN_TIMEOUT_MS` is resolved by `transport::drain_timeout()`, which +`main.rs` calls when it builds the config, so a `default()` anywhere else — a test suite +included — cannot inherit the operator's deadline by accident. ## A strategy is a type, and its signature is `(event, context)` From 3aefb05fb692fa03d8086abe0d10ea10b378aa33 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Fri, 23 Oct 2026 20:02:28 -0400 Subject: [PATCH 15/20] fix(relay): delete the leftover read-guard probe --- .../nostream-relay/src/handlers/subscribe.rs | 7 --- crates/nostream-relay/src/limits.rs | 2 +- crates/nostream-relay/tests/common/mod.rs | 19 ++----- crates/nostream-relay/tests/connection.rs | 12 ++--- crates/nostreamd/src/settings_gate.rs | 8 +++ docs/rewrite/decisions/0004-wire-transport.md | 52 ++++++++++++++----- 6 files changed, 57 insertions(+), 43 deletions(-) diff --git a/crates/nostream-relay/src/handlers/subscribe.rs b/crates/nostream-relay/src/handlers/subscribe.rs index ce5e2c210..11d5581b9 100644 --- a/crates/nostream-relay/src/handlers/subscribe.rs +++ b/crates/nostream-relay/src/handlers/subscribe.rs @@ -251,13 +251,6 @@ async fn fetch_and_send(subscription_id: &str, filters: &[Filter], context: &Han // event"; it is asked per event, as the capture's // `createReadAuthorizationGuard` is, so an AUTH that lands mid-read applies to // the rest of the stream. - let g = context.read_authorization_guard(); - println!( - "judging kind {} now {} auth {:?}", - event.kind, - context.now_secs(), - g.allows(&event) - ); if expiration::is_expired_event(&event, now) || !context.read_authorization_guard().allows(&event) || !matches_any(&event) diff --git a/crates/nostream-relay/src/limits.rs b/crates/nostream-relay/src/limits.rs index 403979677..a0f902078 100644 --- a/crates/nostream-relay/src/limits.rs +++ b/crates/nostream-relay/src/limits.rs @@ -109,7 +109,7 @@ impl Store { /// The pass is what prefers a lapsed key over a live one; a store still full after it /// refuses the new key rather than displacing a live one, and `false` is the caller's cue /// to answer the hit as limited. - fn admit(&mut self, key: &str, now_ms: u64, is_live: impl Fn(&V) -> bool + Copy) -> bool { + fn admit(&mut self, key: &str, now_ms: u64, is_live: impl Fn(&V) -> bool) -> bool { if now_ms >= self.next_sweep_ms { self.sweep(now_ms, is_live); } diff --git a/crates/nostream-relay/tests/common/mod.rs b/crates/nostream-relay/tests/common/mod.rs index 8ea38a985..ddc732cda 100644 --- a/crates/nostream-relay/tests/common/mod.rs +++ b/crates/nostream-relay/tests/common/mod.rs @@ -48,22 +48,9 @@ impl Server { /// Starts a relay with an explicit key source and intervals, minting from `SystemRandom`. pub async fn start_with(fixture: Fixture, keys: RelayKeySource, config: RelayConfig) -> Self { - Self::start_with_random( - fixture, - keys, - config, - Arc::new(nostream_core::random::SystemRandom), - ) - .await - } - - /// Starts a relay with every collaborator named, the randomness included. - pub async fn start_with_random( - fixture: Fixture, - keys: RelayKeySource, - config: RelayConfig, - random: Arc, - ) -> Self { + // The randomness is not a parameter: every caller of this launcher wants system + // entropy, and the oracle drives `Relay::new` itself with the recorded source. + let random = Arc::new(nostream_core::random::SystemRandom); let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let address = listener.local_addr().expect("local addr"); let clock = Arc::new(TestClock::new(NOW_MS)); diff --git a/crates/nostream-relay/tests/connection.rs b/crates/nostream-relay/tests/connection.rs index 07c117731..f4c19e49c 100644 --- a/crates/nostream-relay/tests/connection.rs +++ b/crates/nostream-relay/tests/connection.rs @@ -438,12 +438,10 @@ async fn closing_the_socket_aborts_an_in_flight_read() { /// The gate releases one record at a time, so the AUTH lands after the first record has been /// judged and before the second one is. async fn restricted_read_with(authenticate_mid_read: bool) -> Vec { + // Restricted reads on: they are the gate this test exercises. The zero session TTL these + // settings carry plays no part — the connection below is built with its own. let mut configured = settings(); - let mut nip42 = nostream_relay::testing::nip42_settings(); - // A session that lasts: the settings fixture expires one at once, which is what most - // AUTH tests want and this one does not. - nip42.session_expiry_seconds = Some(3_600); - configured.nip42 = Some(nip42); + configured.nip42 = Some(nostream_relay::testing::nip42_settings()); let fixture = Fixture::new(configured); let gate = fixture.events.gate_reads_per_item(); @@ -461,7 +459,9 @@ async fn restricted_read_with(authenticate_mid_read: bool) -> Vec { .await; let (sink, mut frames) = FrameSink::channel(); - // The session outlives the read: `Nip42Session` carries the TTL it was built with. + // The session outlives the read: this connection is built directly rather than by + // `Connection::accept`, so the TTL is the one named here and the settings' expiry (a + // zero TTL) plays no part. let connection = Arc::new(Connection::new( "127.0.0.1".to_owned(), nostream_core::nip42::Nip42Session::with_challenge("challenge", 3_600), diff --git a/crates/nostreamd/src/settings_gate.rs b/crates/nostreamd/src/settings_gate.rs index 1583e56cf..9462d16fa 100644 --- a/crates/nostreamd/src/settings_gate.rs +++ b/crates/nostreamd/src/settings_gate.rs @@ -4,6 +4,14 @@ //! `nostream_config::load` substitutes the shipped defaults and reports the findings. The //! serve path logs every finding and continues on the defaults; `--check` is the one place //! findings are fatal (exit 2), because its whole job is to report them. +//! +//! This module is also the workspace's one library-position writer to stdout/stderr, so the +//! rule that keeps it that way is worth stating: output belongs to a process's entry point, +//! and a library that writes to a stream the caller does not drain can block it — a per-record +//! write stalls a whole subscription behind a full pipe. These writes are the operator report +//! of a single settings check, reached once from `run` before the relay exists, every one of +//! them newline-terminated (so each flushes per write). Nothing added here may run per +//! request, per message or per streamed record. use nostream_config::LoadOutcome; diff --git a/docs/rewrite/decisions/0004-wire-transport.md b/docs/rewrite/decisions/0004-wire-transport.md index 2dfba9a3d..f96aa2ec7 100644 --- a/docs/rewrite/decisions/0004-wire-transport.md +++ b/docs/rewrite/decisions/0004-wire-transport.md @@ -122,8 +122,17 @@ The capture's two Lua scripts set a TTL on every key (`PEXPIRE key period`, `retain` pass drops every entry past **its own** window. The pass runs at most once per `SWEEP_INTERVAL_MS` (one second), a cadence that belongs to the store — never to the caller's window, or one hit on an hour-long window would postpone reclaiming every -one-second key for an hour — and that keeps a pass costing 1.3–2.0 ms at the ceiling off -the hot path. +one-second key for an hour. + +The pass is *not* free and it is not off the request path: it runs inside the caller's +`hit`, holding the store's mutex, to the end of the lock. At the ceiling one pass takes +1.3–2.0 ms, so the caller that triggers it waits that long and a concurrent `hit` queues +behind it (measured: 0.96–1.16 ms behind a pass that frees nothing). What the gate buys is +that this is the *most* a client can be made to pay: bounded to one pass per second per +store rather than one per hit, which measured 0.035–0.380 % of a core under continuous +load with a slow flood paying 0.482 ms/s. Sweeping on each refused insert instead made a +131,072-key test take 193 s against 0.2 s, and would have given a flooding client +O(store) work per message. Each of the four levels has a store of its own, so a flood of message keys cannot fill the store the event level uses. @@ -139,14 +148,24 @@ store is full. Two limits worth recording rather than discovering in production: -- **The ceiling bounds keys, not bytes.** A sliding key holds one entry per hit, so the - shipped 60-per-minute windows cap a key at 60 entries and the hour window at 720: 65,536 - keys × 720 entries is about 1 GB, reachable only at ~47 million accepted events. +- **The ceiling bounds keys, not bytes.** It is per store, and there are four stores. The + shipped EWMA strategy keeps one entry per key, so a full store holds `MAX_STORE_KEYS` + (65,536) entries and the whole limiter holds four times that. The sliding strategy keeps + one entry per hit instead — its 60-per-minute windows cap a key at 60 entries and its + hour window at 720 — so a full *sliding* store is 65,536 × 720 entries, about 1 GB, and + is reachable only at ~47 million accepted events. +- **A full level is closed to new keys until entries lapse.** Refusal is decided per store, + so a flood that fills one level (measured: ~32,768 addresses or authors per cycle with + the settings the port ships) limits every new address or author *at that level* until + its entries pass their window — up to 60 s per cycle — while the other three levels + keep serving. That is the price of never freeing a live key. - **A relay with no signing key does not rate-limit EVENT at all.** The event key is resolved before the limiter (the capture's `isRateLimited` calls `getRelayPublicKey()`, which throws), so with neither `SECRET` nor `RELAY_PRIVATE_KEY` set every event is answered `error: unable to process event` — AC29's declared outcome — and no window is - consulted. `nostreamd` logs it; a deployment that wants the limiter must set a key. + consulted (`handlers/event.rs`, "no relay signing key; refusing the event"). The relay + logs that at `debug`, so a deployment that wants to see it must run with `debug` enabled; + one that wants the limiter must set a key. ## The accept loop lives in the relay, and serving cannot fail @@ -178,13 +197,20 @@ between two shapes. ## Two orders the capture leaves unspecified, fixed deliberately - **Subscriptions are walked in id order.** The capture's `Map` walks insertion order; the - port's `Connection` keeps a `BTreeMap`, so a drain or a fan-out emits `CLOSED`/`EVENT` - frames in id order. Observable only when a client's ids are not alphabetical, and the - fixture's ids are sorted. -- **The connection-level limit is keyed on the peer address.** The other three levels key - on `Connection::remote_address`, which is derived from the transport; the connection - level is decided before the socket is upgraded (a refused client must never see the - challenge), so it reads the accept loop's `peer` — the same address. + port's `Connection` keeps a `HashMap` and sorts on read (`Connection::subscriptions` sorts + by id), so a drain or a fan-out emits `CLOSED`/`EVENT` frames in id order. Observable only + when a client's ids are not alphabetical, and the fixture's ids are sorted. +- **The connection-level limit is keyed on the peer address, and decided after the + upgrade.** Not before it: the capture's check lives in `onConnection` + (`src/adapters/web-socket-server-adapter.ts:126-143`), which runs after the handshake and + before the adapter is registered, and the port does the same — `Relay::handle_socket` + upgrades (`relay.rs:217`), asks `is_connection_rate_limited` (`:225`), and only then + enters `run_connection` (`:230`). A refused client therefore completes the handshake and + is dropped without a close frame or a registration, as the capture's `terminate()` does. + It keys on the accept loop's `peer` because no `Connection` exists yet; that is the same + string `Connection::remote_address` derives from the transport, since the port + implements neither `network.remoteIpHeader` nor `network.trustedProxies` (the only way + the capture's two keys differ). ## The hidden-content mirror is the relay's From b4e0a04f920cf39ebeb88b8f0376cee364afb3e1 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Fri, 23 Oct 2026 20:33:28 -0400 Subject: [PATCH 16/20] docs(relay): state the limiter's cost and closure per level --- crates/nostream-relay/src/limits.rs | 10 +++- crates/nostreamd/src/settings_gate.rs | 9 ++- docs/rewrite/decisions/0004-wire-transport.md | 57 ++++++++++++++----- 3 files changed, 58 insertions(+), 18 deletions(-) diff --git a/crates/nostream-relay/src/limits.rs b/crates/nostream-relay/src/limits.rs index a0f902078..8de7fc1a4 100644 --- a/crates/nostream-relay/src/limits.rs +++ b/crates/nostream-relay/src/limits.rs @@ -82,7 +82,15 @@ pub const MAX_STORE_KEYS: usize = 65_536; /// The cadence is the store's, never the caller's: armed from a caller's window, one hit on /// an hour-long window would postpone reclaiming every one-second key for an hour. The pass /// itself drops each entry against *its own* window, so one pass per interval is the whole -/// job, and the interval keeps the pass — which holds the store's mutex — off the hot path. +/// job. +/// +/// The cadence bounds *how often* the pass runs, not how long it holds the lock. The pass +/// runs inside [`SlidingWindowRateLimiter::hit`] and [`EwmaRateLimiter::hit`], under the +/// store's mutex to the end of the call, so the caller that triggers it pays for it: a pass +/// over a full store that frees nothing measured 0.32–0.78 ms, and one that reclaims all +/// 65,536 keys measured 4.26–5.06 ms (round-3 performance check, this branch's head). That +/// last figure is the worst single wait a client can be made to pay, and the number to +/// weigh before adding a fifth store or chunking the pass. pub const SWEEP_INTERVAL_MS: u64 = 1_000; /// A keyed store with the TTL the capture's Redis sets: expired keys are reclaimed, and the diff --git a/crates/nostreamd/src/settings_gate.rs b/crates/nostreamd/src/settings_gate.rs index 9462d16fa..521362715 100644 --- a/crates/nostreamd/src/settings_gate.rs +++ b/crates/nostreamd/src/settings_gate.rs @@ -5,9 +5,12 @@ //! serve path logs every finding and continues on the defaults; `--check` is the one place //! findings are fatal (exit 2), because its whole job is to report them. //! -//! This module is also the workspace's one library-position writer to stdout/stderr, so the -//! rule that keeps it that way is worth stating: output belongs to a process's entry point, -//! and a library that writes to a stream the caller does not drain can block it — a per-record +//! This module is also the workspace's one library-position writer to stdout/stderr *outside +//! the `tracing` subscriber*: every other write a library crate makes goes through `tracing` +//! and `telemetry::init`'s `tracing_subscriber::fmt()` layer (whose writer is stdout, and +//! whose output the `EnvFilter` governs). These three bypass that filter, so the rule that +//! keeps them honest is worth stating: output belongs to a process's entry point, and a +//! library that writes to a stream the caller does not drain can block it — a per-record //! write stalls a whole subscription behind a full pipe. These writes are the operator report //! of a single settings check, reached once from `run` before the relay exists, every one of //! them newline-terminated (so each flushes per write). Nothing added here may run per diff --git a/docs/rewrite/decisions/0004-wire-transport.md b/docs/rewrite/decisions/0004-wire-transport.md index f96aa2ec7..9fe71fc5a 100644 --- a/docs/rewrite/decisions/0004-wire-transport.md +++ b/docs/rewrite/decisions/0004-wire-transport.md @@ -125,14 +125,21 @@ caller's window, or one hit on an hour-long window would postpone reclaiming eve one-second key for an hour. The pass is *not* free and it is not off the request path: it runs inside the caller's -`hit`, holding the store's mutex, to the end of the lock. At the ceiling one pass takes -1.3–2.0 ms, so the caller that triggers it waits that long and a concurrent `hit` queues -behind it (measured: 0.96–1.16 ms behind a pass that frees nothing). What the gate buys is -that this is the *most* a client can be made to pay: bounded to one pass per second per -store rather than one per hit, which measured 0.035–0.380 % of a core under continuous -load with a slow flood paying 0.482 ms/s. Sweeping on each refused insert instead made a -131,072-key test take 193 s against 0.2 s, and would have given a flooding client -O(store) work per message. +`hit`, holding the store's mutex for the whole pass, so the caller that triggers it waits +for it and a concurrent `hit` queues behind it. Two passes have to be sized differently: + +- **A pass that frees nothing** — the common one, whenever a store sits at its ceiling + between lapsings — measured 0.32–0.78 ms over 65,536 sliding keys at this head, and + 1.3–2.0 ms in round 2's busier conditions. +- **A pass that reclaims the whole store** — the one that clears a full level and so the + one a client waits behind at the moment the level reopens — measured **4.26–5.06 ms**. + +The one-second cadence bounds *how often* the lock is held, not *how long*: the worst +single stall a client can be made to pay is the reclaiming pass, 5 ms and not 0.8. Behind +a no-op pass a concurrent `hit` waited 0.96–1.16 ms. Taken together the cost measured +0.035–0.380 % of a core under continuous load, with a slow flood paying 0.482 ms/s. +Sweeping on each refused insert instead made a 131,072-key test take 193 s against 0.2 s, +and would have given a flooding client O(store) work per message. Each of the four levels has a store of its own, so a flood of message keys cannot fill the store the event level uses. @@ -154,11 +161,28 @@ Two limits worth recording rather than discovering in production: one entry per hit instead — its 60-per-minute windows cap a key at 60 entries and its hour window at 720 — so a full *sliding* store is 65,536 × 720 entries, about 1 GB, and is reachable only at ~47 million accepted events. -- **A full level is closed to new keys until entries lapse.** Refusal is decided per store, - so a flood that fills one level (measured: ~32,768 addresses or authors per cycle with - the settings the port ships) limits every new address or author *at that level* until - its entries pass their window — up to 60 s per cycle — while the other three levels - keep serving. That is the price of never freeing a live key. +- **A full level is closed to new keys until entries lapse, and the four levels are not + alike.** A store fills at 65,536 keys (measured); how many *clients* that is, and how long + the closure lasts, follow from the windows the key is built with + (`resources/default-settings.yaml`): + + | level | keys per client | clients to fill | closure | + | --- | --- | --- | --- | + | connection | 2 — `{address}:connection:{period}`, windows 1 s and 60 s | 32,768 addresses | ≤ 60 s | + | event | 2 — a 60 s kind window and the global hour window, both hit by an accepted event | 32,768 authors per cycle | ≤ 60 s per cycle, **up to 1 h** under a sustained flood (below) | + | message | 1 — `{address}:message:60000` | 65,536 addresses | ≤ 60 s | + | invite | 1 — `{pubkey}:invites:3600000` | 65,536 pubkeys | **≤ 1 h** (reopens at 1 h + 1 s) | + + The event level's hour keys outlive its 60 s ones: a sustained flood keeps admitting + authors as the minute keys lapse while the hour keys stay (an EWMA key lives + `period_ms.div_ceil(1000) * 1000` = 3,600 s), so the store converges to hour-window keys + and that level too can stay closed for an hour. The invite level has no window shorter + than an hour, so a filled invite store refuses every new pubkey for up to an hour while + the other three levels keep serving. + + The closure is per level and it never touches the others, which is the point of four + stores; it is the price of never freeing a live key. This is also the reason a level's + key count is not a client count: two windows spend two keys per client. - **A relay with no signing key does not rate-limit EVENT at all.** The event key is resolved before the limiter (the capture's `isRateLimited` calls `getRelayPublicKey()`, which throws), so with neither `SECRET` nor `RELAY_PRIVATE_KEY` set every event is @@ -210,7 +234,12 @@ between two shapes. It keys on the accept loop's `peer` because no `Connection` exists yet; that is the same string `Connection::remote_address` derives from the transport, since the port implements neither `network.remoteIpHeader` nor `network.trustedProxies` (the only way - the capture's two keys differ). + the capture's two keys differ). The consequence is worth stating where the limit is + described, because the settings do carry both fields (`settings.rs:71,73`) and the shipped + defaults set `trustedProxies`: the header-derived address lands with the HTTP surface + (daemon/admin), and until then a relay behind a reverse proxy sees every client as the + proxy's address. One connection bucket then serves the deployment, so this level's limit + and the closure recorded below apply to the proxy rather than to each client. ## The hidden-content mirror is the relay's From d73abfbc155bc82ca8efd550f4087216cb0131fb Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Fri, 23 Oct 2026 20:58:08 -0400 Subject: [PATCH 17/20] docs(relay): the event level's cost is set by the kinds a flood picks The table stated one case as the level's property. The settings ship six kind-scoped 60 s windows plus the global hour one, and the key carries the selector set, so an author holds 1, 2 or up to 7 keys and the store fills at between ~9,400 and 65,536 authors. The closure follows the kinds too: 61 s for a single-kind flood, 1 h + 1 s for kinds that match no kind window. Every closure cell also gains the sweep interval: a lapsed key is removed only by a pass, so the bound is window + up to SWEEP_INTERVAL_MS, not the window alone. --- docs/rewrite/decisions/0004-wire-transport.md | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/docs/rewrite/decisions/0004-wire-transport.md b/docs/rewrite/decisions/0004-wire-transport.md index 9fe71fc5a..069624159 100644 --- a/docs/rewrite/decisions/0004-wire-transport.md +++ b/docs/rewrite/decisions/0004-wire-transport.md @@ -168,21 +168,34 @@ Two limits worth recording rather than discovering in production: | level | keys per client | clients to fill | closure | | --- | --- | --- | --- | - | connection | 2 — `{address}:connection:{period}`, windows 1 s and 60 s | 32,768 addresses | ≤ 60 s | - | event | 2 — a 60 s kind window and the global hour window, both hit by an accepted event | 32,768 authors per cycle | ≤ 60 s per cycle, **up to 1 h** under a sustained flood (below) | - | message | 1 — `{address}:message:60000` | 65,536 addresses | ≤ 60 s | - | invite | 1 — `{pubkey}:invites:3600000` | 65,536 pubkeys | **≤ 1 h** (reopens at 1 h + 1 s) | - - The event level's hour keys outlive its 60 s ones: a sustained flood keeps admitting - authors as the minute keys lapse while the hour keys stay (an EWMA key lives - `period_ms.div_ceil(1000) * 1000` = 3,600 s), so the store converges to hour-window keys - and that level too can stay closed for an hour. The invite level has no window shorter - than an hour, so a filled invite store refuses every new pubkey for up to an hour while - the other three levels keep serving. + | connection | 2 — `{address}:connection:{period}`, windows 1 s and 60 s | 32,768 addresses | ≤ 61 s | + | event | **1 to 7** — one 60 s key per kind window the event matches, plus the global hour key | **9,400–65,536 authors** (see below) | **depends on the kind** (see below) | + | message | 1 — `{address}:message:60000` | 65,536 addresses | ≤ 61 s | + | invite | 1 — `{pubkey}:invites:3600000` | 65,536 pubkeys | **≤ 1 h + 1 s** | + + Each cell is window **+ up to one sweep interval**, not the window alone: a lapsed key is + only removed by a pass, the pass runs at most once per `SWEEP_INTERVAL_MS` (1 s), and a + full store refuses a new key until one has run (`limits.rs`, `admit`). The invite row + therefore reopens at 1 h + 1 s rather than 1 h. + + The event level's key count is a property of the *kinds an author uses*, not of the level. + `resources/default-settings.yaml` ships six kind-scoped 60 s windows — `0,3,40,41`; + `1,2,4,42`; `5–7,43–49`; `10000–19999,30000–39999`; `445`; `20000–29999` — and the key is + `{pubkey}:events:{period}:{kinds}`, so the selector set names the key + (`limits.rs`, `event_backoff`). A kind that matches no kind window (`8–10`, `11–39`, + `50–444`) spends only the hour key: 65,536 such authors fill the store and are refused + until 1 h + 1 s. An author posting one kind from each window holds all seven keys, + 65,536 ÷ 7 ≈ 9,400 authors fill the same store; their 60 s keys lapse first, which reopens + it at 61 s, and a *sustained* flood then accumulates hour keys until those alone reach the + ceiling — that is the hour-long closure, and it follows from the kinds such a flood picks, + not from the level. A single-kind flood is the common case of two keys: 32,768 authors, + reopening at 61 s. Both ends were measured in the round-4 probe: kind 1 → 32,768 authors + reopening at 61 s, kind 9999 → 65,536 authors still refused at 61 s and admitting at + 1 h + 1 s. The closure is per level and it never touches the others, which is the point of four stores; it is the price of never freeing a live key. This is also the reason a level's - key count is not a client count: two windows spend two keys per client. + key count is not a client count: the event level spends one, two or seven. - **A relay with no signing key does not rate-limit EVENT at all.** The event key is resolved before the limiter (the capture's `isRateLimited` calls `getRelayPublicKey()`, which throws), so with neither `SECRET` nor `RELAY_PRIVATE_KEY` set every event is From 4f8ac67e312c908b7308f95061e60bfb1808f3d6 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Fri, 23 Oct 2026 21:01:13 -0400 Subject: [PATCH 18/20] docs(relay): name the whole uncovered kind set at the event level The enumeration left out 446-9999 and 40000-65535, which matter because the probe result two lines below uses kind 9999 -- a kind the list did not cover. It is now the exact complement of the six shipped windows. --- docs/rewrite/decisions/0004-wire-transport.md | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/rewrite/decisions/0004-wire-transport.md b/docs/rewrite/decisions/0004-wire-transport.md index 069624159..81ef86dcd 100644 --- a/docs/rewrite/decisions/0004-wire-transport.md +++ b/docs/rewrite/decisions/0004-wire-transport.md @@ -181,17 +181,17 @@ Two limits worth recording rather than discovering in production: The event level's key count is a property of the *kinds an author uses*, not of the level. `resources/default-settings.yaml` ships six kind-scoped 60 s windows — `0,3,40,41`; `1,2,4,42`; `5–7,43–49`; `10000–19999,30000–39999`; `445`; `20000–29999` — and the key is - `{pubkey}:events:{period}:{kinds}`, so the selector set names the key - (`limits.rs`, `event_backoff`). A kind that matches no kind window (`8–10`, `11–39`, - `50–444`) spends only the hour key: 65,536 such authors fill the store and are refused - until 1 h + 1 s. An author posting one kind from each window holds all seven keys, - 65,536 ÷ 7 ≈ 9,400 authors fill the same store; their 60 s keys lapse first, which reopens - it at 61 s, and a *sustained* flood then accumulates hour keys until those alone reach the - ceiling — that is the hour-long closure, and it follows from the kinds such a flood picks, - not from the level. A single-kind flood is the common case of two keys: 32,768 authors, - reopening at 61 s. Both ends were measured in the round-4 probe: kind 1 → 32,768 authors - reopening at 61 s, kind 9999 → 65,536 authors still refused at 61 s and admitting at - 1 h + 1 s. + `{pubkey}:events:{period}:{kinds}`, so the selector set names the key (`limits.rs`, + `event_backoff`). A kind outside all six windows — `8–39`, `50–444`, `446–9999`, + `40000–65535` — spends only the hour key: 65,536 such authors fill the store and are + refused until 1 h + 1 s. An author posting one kind from each window holds all seven keys, + so 65,536 ÷ 7 ≈ 9,400 authors fill the same store; their 60 s keys lapse first, which + reopens it at 61 s, and a *sustained* flood then accumulates hour keys until those alone + reach the ceiling — that is the hour-long closure, and it follows from the kinds such a + flood picks, not from the level. A single-kind flood is the common case of two keys: + 32,768 authors, reopening at 61 s. Both ends were measured in the round-4 probe: kind 1 → + 32,768 authors reopening at 61 s, kind 9999 → 65,536 authors still refused at 61 s and + admitting at 1 h + 1 s. The closure is per level and it never touches the others, which is the point of four stores; it is the price of never freeing a live key. This is also the reason a level's From 27a86fe1b24698a532bb416e046d40d41815fb32 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Fri, 23 Oct 2026 21:22:54 -0400 Subject: [PATCH 19/20] chore(relay): add an empty changeset for the port The changeset check requires a file whenever a package directory changes, and this branch changes only Rust crates, so there is nothing to release. Empty frontmatter declares exactly that. --- .changeset/quiet-relays-port.md | 8 ++++++++ .changeset/relay-wire-handlers.md | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 .changeset/quiet-relays-port.md create mode 100644 .changeset/relay-wire-handlers.md diff --git a/.changeset/quiet-relays-port.md b/.changeset/quiet-relays-port.md new file mode 100644 index 000000000..c9cefdfb1 --- /dev/null +++ b/.changeset/quiet-relays-port.md @@ -0,0 +1,8 @@ +--- +--- + +Port the relay's wire handlers, strategies and rate limits into the Rust rewrite. + +No JavaScript package changes in this branch, so there is nothing to release; the +changeset check asks for a file because the diff touches a package directory, and its +own guidance for that case is `changeset add --empty`. diff --git a/.changeset/relay-wire-handlers.md b/.changeset/relay-wire-handlers.md new file mode 100644 index 000000000..a31724d1e --- /dev/null +++ b/.changeset/relay-wire-handlers.md @@ -0,0 +1,19 @@ +--- +--- + +feat(relay): answer a real client over the wire, with the capture's rate limits + +`nostream-relay` now serves the wire surface job 008 specified: connection control, +`EVENT` ingestion through the strategy and limiter pipeline, `REQ`/`CLOSE` with +streaming `EVENT` and `EOSE`, NIP-42 `AUTH` with restricted reads, `COUNT`, and the +`OK`/`NOTICE`/`CLOSED` replies the capture sends. The limiter keeps four per-level +stores with lapse-preferring eviction and a store-owned sweep, so a refused level +reopens within one window plus one sweep interval. + +Parity is measured rather than asserted. `ts_case_parity` runs 462 cases of the +TypeScript census against the Rust handlers; `wire_parity` replays 16 captured +sessions, 78 steps, and compares frames byte for byte with one declared tolerance. + +The TypeScript tree is untouched: no `src/` or `test/` file changes, so the published +package has no user-visible change. Decisions are recorded in +`docs/rewrite/decisions/0004-wire-transport.md`. From 74ea36e2d3c2a7914f1e5819facfdc8304614b50 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Fri, 23 Oct 2026 21:23:19 -0400 Subject: [PATCH 20/20] chore(changeset): keep the prose entry, drop the duplicate empty one --- .changeset/quiet-relays-port.md | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 .changeset/quiet-relays-port.md diff --git a/.changeset/quiet-relays-port.md b/.changeset/quiet-relays-port.md deleted file mode 100644 index c9cefdfb1..000000000 --- a/.changeset/quiet-relays-port.md +++ /dev/null @@ -1,8 +0,0 @@ ---- ---- - -Port the relay's wire handlers, strategies and rate limits into the Rust rewrite. - -No JavaScript package changes in this branch, so there is nothing to release; the -changeset check asks for a file because the diff touches a package directory, and its -own guidance for that case is `changeset add --empty`.