diff --git a/.changeset/relay-event-strategies.md b/.changeset/relay-event-strategies.md new file mode 100644 index 000000000..63708a64b --- /dev/null +++ b/.changeset/relay-event-strategies.md @@ -0,0 +1,23 @@ +--- +--- + +feat(relay): fill the fourteen event strategies, with a real OTS verifier behind them + +`nostream-relay` now answers every kind its selector routes: contact list, delete, DVM job +request, ephemeral, gift wrap, group, invite request, join request, leave request, +parameterized replaceable, replaceable, report, timestamp and vanish each emit the frame +their `src/handlers/event-strategies/` counterpart does — the same `OK` text, the same +broadcast, and the same best-effort block after the store. Two seams carry what the arms +call and the relay does not own: a `WotGraph` trait (`src/wot.rs`) for the follow-list and +distance calls, and `nostream_core::nip03`'s OpenTimestamps parser for kind-1040 proofs, +whose reason strings reach the client verbatim. `nostream-core` gains NIP-56 target +extraction, report scoring and the NIP-43 request helpers; `nostream-storage` gains +`StorageError::DuplicateEvent`, produced from `23505` on `events_event_id_unique`, so the +two catching arms answer `rejected: event already exists` without a driver's message ever +reaching a client. + +Parity is measured rather than asserted. The census grew from 557 to 658 cases (36 spec +files, 612 mapped across 200 rows to 182 live Rust tests, 46 exempt with the job that owns +them), and the fourteen files' 101 cases are carried by the new `tests/strategies.rs` +target's 100 tests. Decisions are recorded in +`docs/rewrite/decisions/0006-event-strategies.md`. diff --git a/Cargo.lock b/Cargo.lock index 293303f88..eb3ccdf0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1194,6 +1194,7 @@ dependencies = [ name = "nostream-relay" version = "0.1.0" dependencies = [ + "base64", "chrono", "futures-util", "hex", diff --git a/crates/nostream-core/src/nip03.rs b/crates/nostream-core/src/nip03.rs index 2a169f8a9..f3006f0be 100644 --- a/crates/nostream-core/src/nip03.rs +++ b/crates/nostream-core/src/nip03.rs @@ -1,13 +1,760 @@ //! NIP-03: OpenTimestamps attestations. Mirrors `src/utils/nip03.ts`. +//! +//! A relay cannot verify a proof against the Bitcoin chain — that needs a node, and clients do +//! it themselves — so this is a *structural* validator: the magic header, the version, the file +//! hash op, the digest, and the shape of the commitment tree, then the three relay-observable +//! requirements of NIP-03 (a sha256 file hash, a digest equal to the attested event id, and at +//! least one Bitcoin block-header attestation). +//! +//! Every rejection is a reason string, and they are the capture's verbatim, because the +//! kind-1040 strategy puts them in the event's `OK` frame after `invalid: `. -use crate::error::CoreError; -use crate::event::Event; +use base64::Engine; +use base64::alphabet; +use base64::engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig}; -/// Extracts the OpenTimestamps attestation attached to an event. +use crate::constants::MAX_SAFE_INTEGER; +use crate::event::EventId; + +/// The 31-byte magic header (`MAGIC_HEADER`, `src/utils/nip03.ts:27-30`). +const MAGIC_HEADER: [u8; 31] = [ + 0x00, 0x4f, 0x70, 0x65, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, 0x00, + 0x00, 0x50, 0x72, 0x6f, 0x6f, 0x66, 0x00, 0xbf, 0x89, 0xe2, 0xe8, 0x84, 0xe8, 0x92, 0x94, +]; + +/// The 8-byte attestation type tags (`src/utils/nip03.ts:34-37`). +const BITCOIN_BLOCK_HEADER_ATTESTATION: [u8; 8] = [0x05, 0x88, 0x96, 0x0d, 0x73, 0xd7, 0x19, 0x01]; +const PENDING_ATTESTATION: [u8; 8] = [0x83, 0xdf, 0xe3, 0x0d, 0x2e, 0xf9, 0x0c, 0x8e]; +const LITECOIN_BLOCK_HEADER_ATTESTATION: [u8; 8] = [0x06, 0x86, 0x9a, 0x0d, 0x73, 0xd7, 0x1b, 0x45]; +const ETHEREUM_BLOCK_HEADER_ATTESTATION: [u8; 8] = [0x30, 0xfe, 0x80, 0x87, 0xb5, 0xc7, 0xea, 0xd7]; + +/// Operation tag bytes used in the commitment tree (`src/utils/nip03.ts:40-47`). +const OP_SHA1: u8 = 0x02; +const OP_RIPEMD160: u8 = 0x03; +const OP_SHA256: u8 = 0x08; +const OP_KECCAK256: u8 = 0x67; +const OP_APPEND: u8 = 0xf0; +const OP_PREPEND: u8 = 0xf1; +const OP_REVERSE: u8 = 0xf2; +const OP_HEXLIFY: u8 = 0xf3; + +/// The two structural markers in a `Timestamp` node (`src/utils/nip03.ts:50-51`). +const TAG_BRANCH: u8 = 0xff; +const TAG_ATTESTATION: u8 = 0x00; + +/// The ceilings that keep a pathological proof from exhausting memory or the stack +/// (`src/utils/nip03.ts:57-60`). +const MAX_OTS_BYTES: usize = 16 * 1024; +const MAX_VARBYTES_LENGTH: usize = 8 * 1024; +const MAX_RECURSION_DEPTH: usize = 128; + +/// Only v1 of the file format is defined today (`src/utils/nip03.ts:65`). +const SUPPORTED_OTS_VERSION: u64 = 1; + +/// The hash op a proof's header names for the timestamped file (`OtsFileSummary.fileHashOp`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileHashOp { + Sha1, + Ripemd160, + Sha256, + Keccak256, +} + +impl FileHashOp { + /// The spelling the capture's summary reports, and the one its reason strings carry. + pub const fn as_str(self) -> &'static str { + match self { + Self::Sha1 => "sha1", + Self::Ripemd160 => "ripemd160", + Self::Sha256 => "sha256", + Self::Keccak256 => "keccak256", + } + } + + /// The digest length this op produces (`parseFileHashOp`, `src/utils/nip03.ts:279-292`). + const fn digest_length(self) -> usize { + match self { + Self::Sha1 | Self::Ripemd160 => 20, + Self::Sha256 | Self::Keccak256 => 32, + } + } +} + +/// The chain an attestation commits to. An unrecognised tag is `Unknown` rather than a +/// hard failure: the format is explicitly extensible (`src/utils/nip03.ts:67-74`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OtsAttestationKind { + Bitcoin, + Pending, + Litecoin, + Ethereum, + Unknown, +} + +/// One attestation found in the commitment tree. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OtsAttestation { + pub kind: OtsAttestationKind, + /// The block height a block-header attestation carries, when it parsed cleanly. + pub height: Option, +} + +/// What a parsed `.ots` file says (`OtsFileSummary`, `src/utils/nip03.ts:92-100`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OtsSummary { + pub version: u64, + pub file_hash_op: FileHashOp, + /// The timestamped file's digest: 20 bytes for sha1/ripemd160, 32 for sha256/keccak256. + pub digest: Vec, + pub attestations: Vec, +} + +/// Parses a binary `.ots` file (`parseOtsFile`, `src/utils/nip03.ts:298-344`). /// -/// Phase 1 stub — job 005; will decode the `ots` tag and verify its digest; see `src/utils/nip03.ts`. -pub fn parse_attestation(_event: &Event) -> Result, CoreError> { - Err(CoreError::NotImplemented { - module: "core::nip03::parse_attestation", +/// The capture never throws — it returns a result union — so every malformed input is an +/// `Err` carrying the capture's reason. +pub fn parse_ots_file(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Err("empty ots file".to_owned()); + } + + if bytes.len() > MAX_OTS_BYTES { + return Err(format!("ots file exceeds {MAX_OTS_BYTES} bytes")); + } + + let mut reader = Reader::new(bytes); + + if reader.read_bytes(MAGIC_HEADER.len())? != MAGIC_HEADER.as_slice() { + return Err("invalid ots magic header".to_owned()); + } + + let version = reader.read_varuint()?; + + if version != SUPPORTED_OTS_VERSION { + return Err(format!("unsupported ots version {version}")); + } + + let file_hash_op = parse_file_hash_op(reader.read_byte()?)?; + let digest = reader.read_bytes(file_hash_op.digest_length())?.to_vec(); + + let mut attestations = Vec::new(); + read_timestamp(&mut reader, &mut attestations, 0)?; + + if reader.remaining() != 0 { + return Err(format!( + "trailing bytes after ots proof ({} left)", + reader.remaining() + )); + } + + Ok(OtsSummary { + version, + file_hash_op, + digest, + attestations, }) } + +/// The capture decodes with Node's permissive `Buffer.from(content, 'base64')`, which discards +/// the final block's unused trailing bits (`src/utils/nip03.ts:368`). The strict `STANDARD` +/// engine refuses such a proof, which the capture accepts — a wire-visible +/// `invalid: content is not valid base64` for a proof that is structurally sound. +const LENIENT: GeneralPurpose = GeneralPurpose::new( + &alphabet::STANDARD, + GeneralPurposeConfig::new().with_decode_allow_trailing_bits(true), +); + +/// Validates the base64-encoded `.ots` proof in a kind-1040 event's content +/// (`validateOtsProof`, `src/utils/nip03.ts:357-396`). +/// +/// The capture's five rules, in its order: a non-empty content, the base64 guard, a sha256 +/// file hash, a digest equal to the attested event id, and at least one Bitcoin attestation. +/// The capture's sixth check — that the target id is 32 hex bytes — is carried by +/// [`EventId`]'s type here, so it has no runtime form. +pub fn validate_ots_proof(base64_content: &str, target_event_id: &EventId) -> Result<(), String> { + if base64_content.is_empty() { + return Err("content is empty; expected base64-encoded ots file".to_owned()); + } + + // Node's permissive decoder silently drops bad characters; the capture fails loudly + // instead, so a client gets a reason rather than a proof that "parsed". + if !is_base64(base64_content) || !base64_content.len().is_multiple_of(4) { + return Err("content is not valid base64".to_owned()); + } + + let bytes = LENIENT + .decode(base64_content) + .map_err(|_| "content is not valid base64".to_owned())?; + + let summary = + parse_ots_file(&bytes).map_err(|reason| format!("invalid ots proof: {reason}"))?; + + if summary.file_hash_op != FileHashOp::Sha256 { + return Err(format!( + "ots proof must use sha256 file hash op (got {})", + summary.file_hash_op.as_str() + )); + } + + if summary.digest.as_slice() != target_event_id.as_bytes().as_slice() { + return Err("ots proof digest does not match the referenced event id".to_owned()); + } + + if !summary + .attestations + .iter() + .any(|attestation| attestation.kind == OtsAttestationKind::Bitcoin) + { + return Err("ots proof must contain at least one bitcoin attestation".to_owned()); + } + + Ok(()) +} + +/// A bounds-checked cursor over an `.ots` file (`OtsReader`, `src/utils/nip03.ts:110-167`). +struct Reader<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> Reader<'a> { + const fn new(bytes: &'a [u8]) -> Self { + Self { bytes, offset: 0 } + } + + const fn remaining(&self) -> usize { + self.bytes.len() - self.offset + } + + fn read_bytes(&mut self, count: usize) -> Result<&'a [u8], String> { + let end = self + .offset + .checked_add(count) + .filter(|end| *end <= self.bytes.len()); + + let Some(end) = end else { + return Err(format!( + "unexpected end of input (need {count} bytes, have {})", + self.remaining() + )); + }; + + let slice = &self.bytes[self.offset..end]; + self.offset = end; + + Ok(slice) + } + + fn read_byte(&mut self) -> Result { + Ok(self.read_bytes(1)?[0]) + } + + /// A LEB128 varuint, little-endian base-128 with an MSB continuation bit + /// (`readVarUint`, `src/utils/nip03.ts:140-158`). + fn read_varuint(&mut self) -> Result { + let mut value: u64 = 0; + let mut shift = 0; + + loop { + if shift > 56 { + return Err("varint overflow".to_owned()); + } + + let byte = self.read_byte()?; + value += u64::from(byte & 0x7f) << shift; + + if byte & 0x80 == 0 { + break; + } + + shift += 7; + } + + if value > MAX_SAFE_INTEGER { + return Err("varint exceeds safe integer range".to_owned()); + } + + Ok(value) + } + + fn read_varbytes(&mut self) -> Result<&'a [u8], String> { + let length = self.read_varuint()?; + + let Ok(length) = usize::try_from(length) else { + return Err(format!( + "varbytes length {length} exceeds maximum {MAX_VARBYTES_LENGTH}" + )); + }; + + if length > MAX_VARBYTES_LENGTH { + return Err(format!( + "varbytes length {length} exceeds maximum {MAX_VARBYTES_LENGTH}" + )); + } + + self.read_bytes(length) + } +} + +/// `parseFileHashOp` (`src/utils/nip03.ts:279-292`). +fn parse_file_hash_op(op: u8) -> Result { + match op { + OP_SHA1 => Ok(FileHashOp::Sha1), + OP_RIPEMD160 => Ok(FileHashOp::Ripemd160), + OP_SHA256 => Ok(FileHashOp::Sha256), + OP_KECCAK256 => Ok(FileHashOp::Keccak256), + _ => Err(format!("unsupported file hash op 0x{op:02x}")), + } +} + +/// `classifyAttestationTag` (`src/utils/nip03.ts:169-183`). +fn classify_attestation(tag: &[u8]) -> OtsAttestationKind { + if tag == BITCOIN_BLOCK_HEADER_ATTESTATION.as_slice() { + OtsAttestationKind::Bitcoin + } else if tag == PENDING_ATTESTATION.as_slice() { + OtsAttestationKind::Pending + } else if tag == LITECOIN_BLOCK_HEADER_ATTESTATION.as_slice() { + OtsAttestationKind::Litecoin + } else if tag == ETHEREUM_BLOCK_HEADER_ATTESTATION.as_slice() { + OtsAttestationKind::Ethereum + } else { + OtsAttestationKind::Unknown + } +} + +/// `readAttestation` (`src/utils/nip03.ts:185-210`): the type tag, then the length-prefixed +/// payload, whose first varuint is the block height for the three block-header chains. +fn read_attestation(reader: &mut Reader<'_>) -> Result { + let tag = reader.read_bytes(8)?; + let kind = classify_attestation(tag); + let payload = reader.read_varbytes()?; + + let height = match kind { + OtsAttestationKind::Bitcoin + | OtsAttestationKind::Litecoin + | OtsAttestationKind::Ethereum => Reader::new(payload).read_varuint().ok(), + OtsAttestationKind::Pending | OtsAttestationKind::Unknown => None, + }; + + Ok(OtsAttestation { kind, height }) +} + +/// `readOpArgIfAny` (`src/utils/nip03.ts:227-239`). +fn skip_op_argument(reader: &mut Reader<'_>, op: u8) -> Result<(), String> { + if op == OP_APPEND || op == OP_PREPEND { + // A binary op carries one length-prefixed argument, which the validator skips over; + // `read_varbytes` still enforces the length ceiling. + reader.read_varbytes()?; + + return Ok(()); + } + + if matches!( + op, + OP_SHA1 | OP_RIPEMD160 | OP_SHA256 | OP_KECCAK256 | OP_REVERSE | OP_HEXLIFY + ) { + return Ok(()); + } + + Err(format!("unknown op tag 0x{op:02x}")) +} + +/// `readTimestamp` (`src/utils/nip03.ts:251-268`): +/// `Timestamp := (0xff SubItem)* SubItem`. +fn read_timestamp( + reader: &mut Reader<'_>, + attestations: &mut Vec, + depth: usize, +) -> Result<(), String> { + if depth > MAX_RECURSION_DEPTH { + return Err(format!("timestamp tree too deep (>{MAX_RECURSION_DEPTH})")); + } + + loop { + let tag = reader.read_byte()?; + + if tag == TAG_BRANCH { + let inner = reader.read_byte()?; + read_sub_item(reader, inner, attestations, depth + 1)?; + + continue; + } + + return read_sub_item(reader, tag, attestations, depth + 1); + } +} + +/// `readSubItem` (`src/utils/nip03.ts:270-277`): an attestation leaf, or an inner commitment. +fn read_sub_item( + reader: &mut Reader<'_>, + tag: u8, + attestations: &mut Vec, + depth: usize, +) -> Result<(), String> { + if tag == TAG_ATTESTATION { + attestations.push(read_attestation(reader)?); + + return Ok(()); + } + + skip_op_argument(reader, tag)?; + read_timestamp(reader, attestations, depth) +} + +/// `^[A-Za-z0-9+/]+={0,2}$` (`src/utils/nip03.ts:364`). +fn is_base64(value: &str) -> bool { + let body = value.trim_end_matches('='); + + !body.is_empty() + && value.len() - body.len() <= 2 + && body + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'+' || byte == b'/') +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use super::*; + use base64::engine::general_purpose::STANDARD; + + const TARGET: &str = "e71c6ea722987debdb60f81f9ea4f604b5ac0664120dd64fb9d23abc4ec7c323"; + + /// The eight-byte Bitcoin block-header attestation tag. + const BITCOIN_TAG: [u8; 8] = [0x05, 0x88, 0x96, 0x0d, 0x73, 0xd7, 0x19, 0x01]; + /// The eight-byte Litecoin block-header attestation tag. + const LITECOIN_TAG: [u8; 8] = [0x06, 0x86, 0x9a, 0x0d, 0x73, 0xd7, 0x1b, 0x45]; + + fn write_varuint(mut value: u64) -> Vec { + let mut out = Vec::new(); + + loop { + let mut byte = (value & 0x7f) as u8; + value >>= 7; + + if value != 0 { + byte |= 0x80; + } + + out.push(byte); + + if value == 0 { + return out; + } + } + } + + fn attestation(tag: [u8; 8], height: u64) -> Vec { + let payload = write_varuint(height); + let mut bytes = vec![TAG_ATTESTATION]; + bytes.extend(tag); + bytes.extend(write_varuint(payload.len() as u64)); + bytes.extend(payload); + + bytes + } + + /// A proof whose digest is `digest` and whose single leaf is a Bitcoin attestation + /// (`buildValidOtsForDigest`, `timestamp-event-strategy.spec.ts:56-60`). + fn proof_for(digest: &[u8]) -> Vec { + let mut bytes = MAGIC_HEADER.to_vec(); + bytes.push(1); + bytes.push(OP_SHA256); + bytes.extend(digest); + bytes.extend(attestation(BITCOIN_TAG, 810_391)); + + bytes + } + + fn target_id() -> EventId { + TARGET.parse().unwrap() + } + + fn base64(bytes: &[u8]) -> String { + STANDARD.encode(bytes) + } + + #[test] + fn a_bitcoin_attestation_parses_with_its_height() { + let summary = parse_ots_file(&proof_for(target_id().as_bytes())).unwrap(); + + assert_eq!(summary.version, 1); + assert_eq!(summary.file_hash_op, FileHashOp::Sha256); + assert_eq!(summary.digest, target_id().as_bytes().to_vec()); + assert_eq!( + summary.attestations, + vec![OtsAttestation { + kind: OtsAttestationKind::Bitcoin, + height: Some(810_391), + }] + ); + } + + #[test] + fn a_branch_is_walked_and_every_leaf_collected() { + let mut bytes = MAGIC_HEADER.to_vec(); + bytes.push(1); + bytes.push(OP_SHA256); + bytes.extend(target_id().as_bytes()); + // `(0xff SubItem)* SubItem`: a branch over a unary op and a Litecoin leaf, then the + // final sub-item, a Bitcoin leaf. + bytes.push(TAG_BRANCH); + bytes.push(OP_REVERSE); + bytes.extend(attestation(LITECOIN_TAG, 7)); + bytes.extend(attestation(BITCOIN_TAG, 810_391)); + + let summary = parse_ots_file(&bytes).unwrap(); + + assert_eq!( + summary.attestations, + vec![ + OtsAttestation { + kind: OtsAttestationKind::Litecoin, + height: Some(7), + }, + OtsAttestation { + kind: OtsAttestationKind::Bitcoin, + height: Some(810_391), + }, + ] + ); + } + + #[test] + fn the_magic_header_is_required() { + let mut bytes = proof_for(target_id().as_bytes()); + bytes[0] = 0xff; + + assert_eq!( + parse_ots_file(&bytes), + Err("invalid ots magic header".to_owned()) + ); + } + + #[test] + fn an_unsupported_version_is_refused() { + let mut bytes = proof_for(target_id().as_bytes()); + bytes[MAGIC_HEADER.len()] = 2; + + assert_eq!( + parse_ots_file(&bytes), + Err("unsupported ots version 2".to_owned()) + ); + } + + #[test] + fn an_unsupported_file_hash_op_is_refused() { + let mut bytes = proof_for(target_id().as_bytes()); + bytes[MAGIC_HEADER.len() + 1] = 0x42; + + assert_eq!( + parse_ots_file(&bytes), + Err("unsupported file hash op 0x42".to_owned()) + ); + } + + #[test] + fn an_empty_file_is_refused() { + assert_eq!(parse_ots_file(&[]), Err("empty ots file".to_owned())); + } + + #[test] + fn an_oversize_file_is_refused() { + assert_eq!( + parse_ots_file(&vec![0u8; MAX_OTS_BYTES + 1]), + Err(format!("ots file exceeds {MAX_OTS_BYTES} bytes")) + ); + } + + #[test] + fn a_truncated_file_is_refused() { + let bytes = proof_for(target_id().as_bytes()); + + assert!( + parse_ots_file(&bytes[..MAGIC_HEADER.len()]).is_err(), + "a file that ends inside the header has no digest" + ); + } + + #[test] + fn trailing_bytes_are_refused() { + let mut bytes = proof_for(target_id().as_bytes()); + bytes.push(0x00); + + assert_eq!( + parse_ots_file(&bytes), + Err("trailing bytes after ots proof (1 left)".to_owned()) + ); + } + + #[test] + fn an_unknown_op_tag_is_refused() { + let mut bytes = MAGIC_HEADER.to_vec(); + bytes.push(1); + bytes.push(OP_SHA256); + bytes.extend(target_id().as_bytes()); + bytes.push(0x99); + + assert_eq!( + parse_ots_file(&bytes), + Err("unknown op tag 0x99".to_owned()) + ); + } + + #[test] + fn a_varint_that_never_terminates_is_refused() { + let mut bytes = MAGIC_HEADER.to_vec(); + bytes.extend(std::iter::repeat_n(0x80u8, 9)); + + assert_eq!(parse_ots_file(&bytes), Err("varint overflow".to_owned())); + } + + #[test] + fn a_tree_past_the_depth_cap_is_refused() { + let mut bytes = MAGIC_HEADER.to_vec(); + bytes.push(1); + bytes.push(OP_SHA256); + bytes.extend(target_id().as_bytes()); + // Each `reverse` (a unary op) adds one level without an argument. + bytes.extend(std::iter::repeat_n(OP_REVERSE, MAX_RECURSION_DEPTH + 2)); + + assert_eq!( + parse_ots_file(&bytes), + Err(format!("timestamp tree too deep (>{MAX_RECURSION_DEPTH})")) + ); + } + + #[test] + fn a_proof_for_the_wrong_digest_is_refused() { + let other = [0xd7u8; 32]; + + assert_eq!( + validate_ots_proof(&base64(&proof_for(&other)), &target_id()), + Err("ots proof digest does not match the referenced event id".to_owned()) + ); + } + + #[test] + fn a_proof_without_a_bitcoin_attestation_is_refused() { + let mut bytes = MAGIC_HEADER.to_vec(); + bytes.push(1); + bytes.push(OP_SHA256); + bytes.extend(target_id().as_bytes()); + bytes.extend(attestation(LITECOIN_TAG, 7)); + + assert_eq!( + validate_ots_proof(&base64(&bytes), &target_id()), + Err("ots proof must contain at least one bitcoin attestation".to_owned()) + ); + } + + #[test] + fn a_proof_that_is_not_sha256_is_refused() { + let mut bytes = MAGIC_HEADER.to_vec(); + bytes.push(1); + bytes.push(OP_SHA1); + bytes.extend([0u8; 20]); + bytes.extend(attestation(BITCOIN_TAG, 1)); + + assert_eq!( + validate_ots_proof(&base64(&bytes), &target_id()), + Err("ots proof must use sha256 file hash op (got sha1)".to_owned()) + ); + } + + #[test] + fn empty_content_is_refused() { + assert_eq!( + validate_ots_proof("", &target_id()), + Err("content is empty; expected base64-encoded ots file".to_owned()) + ); + } + + #[test] + fn content_that_is_not_base64_is_refused() { + assert_eq!( + validate_ots_proof("not base64!", &target_id()), + Err("content is not valid base64".to_owned()) + ); + assert_eq!( + validate_ots_proof("AAAAA", &target_id()), + Err("content is not valid base64".to_owned()) + ); + } + + #[test] + fn content_that_is_not_an_ots_file_is_refused() { + let content = + base64(b"this is long enough to reach the header check, but it is not a proof"); + + assert_eq!( + validate_ots_proof(&content, &target_id()), + Err("invalid ots proof: invalid ots magic header".to_owned()) + ); + } + + #[test] + fn a_valid_proof_is_accepted() { + assert_eq!( + validate_ots_proof(&base64(&proof_for(target_id().as_bytes())), &target_id()), + Ok(()) + ); + } + + /// Re-encodes the final base64 block with one non-zero pad bit: the byte string is the + /// same, Node's `Buffer.from` decodes it, and a canonical decoder refuses it. + fn flip_unused_trailing_bits(encoded: &str) -> String { + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + + let mut characters: Vec = encoded.bytes().collect(); + let padding = characters + .iter() + .rev() + .take_while(|byte| **byte == b'=') + .count(); + assert!( + padding == 1 || padding == 2, + "a final group with unused bits" + ); + let index = characters.len() - padding - 1; + let value = ALPHABET + .iter() + .position(|byte| *byte == characters[index]) + .expect("a base64 data character"); + + characters[index] = ALPHABET[value ^ 1]; + + String::from_utf8(characters).expect("the alphabet is ASCII") + } + + #[test] + fn a_non_canonical_re_encoding_of_a_valid_proof_is_accepted() { + // The capture decodes with Node's permissive `Buffer.from(content, 'base64')`, which + // discards the final block's unused bits (`src/utils/nip03.ts:368`), so a proof + // re-encoded with a set pad bit is accepted. A strict decoder refuses it, and the + // refusal would reach the client as `invalid: content is not valid base64`. + // + // A two-byte block height makes the proof 77 bytes, so its base64 ends in one `=` + // and the last data character carries the two unused bits this case needs. + let mut bytes = MAGIC_HEADER.to_vec(); + bytes.push(1); + bytes.push(OP_SHA256); + bytes.extend(target_id().as_bytes()); + bytes.extend(attestation(BITCOIN_TAG, 300)); + + assert_eq!(bytes.len(), 77); + + let canonical = base64(&bytes); + let re_encoded = flip_unused_trailing_bits(&canonical); + + assert_ne!(re_encoded, canonical); + assert_eq!(validate_ots_proof(&canonical, &target_id()), Ok(())); + assert_eq!(validate_ots_proof(&re_encoded, &target_id()), Ok(())); + assert!( + STANDARD.decode(&re_encoded).is_err(), + "the strict engine refuses the non-canonical encoding" + ); + assert_eq!(LENIENT.decode(&re_encoded).unwrap(), bytes); + } +} diff --git a/crates/nostream-core/src/nip43.rs b/crates/nostream-core/src/nip43.rs index cc551ece1..a5f93a3c7 100644 --- a/crates/nostream-core/src/nip43.rs +++ b/crates/nostream-core/src/nip43.rs @@ -110,3 +110,68 @@ pub fn build_invite_code_event( pub fn is_join_request(kind: Kind) -> bool { kind == Kind(28_934) } + +/// `getClaimTag` (`src/utils/nip43.ts:19-22`): the value of the first `claim` tag that carries +/// one. +pub fn get_claim_tag(event: &Event) -> Option<&str> { + event + .tags + .iter() + .find(|tag| tag.len() >= 2 && tag[0] == "claim") + .map(|tag| tag[1].as_str()) +} + +/// How far a join or leave request's `created_at` may sit from the relay's clock +/// (`MAX_TIMESTAMP_DELTA_SECONDS`, `src/utils/nip43.ts:26`). Same tolerance as NIP-42. +pub const MAX_TIMESTAMP_DELTA_SECONDS: i64 = 600; + +/// `isNip43RequestTimestampValid` (`src/utils/nip43.ts:28-29`), with the reference time taken +/// from the caller so the relay's clock decides it rather than `Date.now()`. +pub fn is_request_timestamp_valid(created_at: i64, now_secs: i64) -> bool { + now_secs.saturating_sub(created_at).saturating_abs() <= MAX_TIMESTAMP_DELTA_SECONDS +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use super::*; + + fn event_with(tags: Vec>) -> Event { + Event { + id: crate::event::EventId::from_byte_array([1; 32]), + pubkey: crate::event::Pubkey::from_byte_array([2; 32]), + created_at: 1_700_000_000, + kind: Kind(28_934), + tags: tags + .into_iter() + .map(|tag| tag.into_iter().map(str::to_owned).collect()) + .collect(), + content: String::new(), + sig: crate::event::Signature::from_byte_array([0; 64]), + } + } + + #[test] + fn the_claim_tag_is_the_first_one_with_a_value() { + let event = event_with(vec![vec!["claim"], vec!["-,", "x"], vec!["claim", "code"]]); + + assert_eq!(get_claim_tag(&event), Some("code")); + } + + #[test] + fn an_event_without_a_claim_tag_has_none() { + assert_eq!(get_claim_tag(&event_with(vec![vec!["p", "pubkey"]])), None); + } + + #[test] + fn the_timestamp_window_is_six_hundred_seconds_either_way() { + let now = 1_700_000_000; + + assert!(is_request_timestamp_valid(now, now)); + assert!(is_request_timestamp_valid(now - 600, now)); + assert!(is_request_timestamp_valid(now + 600, now)); + assert!(!is_request_timestamp_valid(now - 601, now)); + assert!(!is_request_timestamp_valid(now + 3600, now)); + } +} diff --git a/crates/nostream-core/src/nip56.rs b/crates/nostream-core/src/nip56.rs index df3123759..7a0cbfeac 100644 --- a/crates/nostream-core/src/nip56.rs +++ b/crates/nostream-core/src/nip56.rs @@ -1,23 +1,320 @@ //! NIP-56: reporting. Mirrors `src/utils/nip56.ts`. +//! +//! A report event names what it objects to in `p` and `e` tags whose third element is the +//! report type. The first `p` tag and the first `e` tag of the same type collapse into one +//! target; two explicit, different types become two targets; every further tag is its own +//! single-field target. A report naming nothing valid still yields one empty target, so the +//! caller can tell "nothing to act on" from "no tags at all". +//! +//! The 64-hex check is case-insensitive here (`HEX64_PATTERN`, `src/utils/nip56.ts:16`), +//! unlike the lowercase-only checks in the relay's delete, gift-wrap, group and timestamp +//! arms; the persistence path hex-decodes the value, so both cases name the same bytes. ADR +//! 0006 records why the asymmetry is kept rather than normalised. -use crate::error::CoreError; -use crate::event::{Event, EventId, Pubkey}; +use crate::event::{EventId, Pubkey, Tag}; -/// The parsed target of a kind-1984 report. +/// The report types NIP-56 defines (`ReportType`, `src/@types/report.ts`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ReportType { + Nudity, + Malware, + Profanity, + Illegal, + Spam, + Impersonation, + Other, +} + +impl ReportType { + /// The spelling the `reports.report_type` column holds (`src/@types/report.ts`). + pub const fn as_str(self) -> &'static str { + match self { + Self::Nudity => "nudity", + Self::Malware => "malware", + Self::Profanity => "profanity", + Self::Illegal => "illegal", + Self::Spam => "spam", + Self::Impersonation => "impersonation", + Self::Other => "other", + } + } + + /// One of the seven spellings (`isValidReportType`, `src/utils/nip56.ts:13-14`). Anything + /// else is not a report type, and the caller reads it as [`ReportType::Other`]. + pub fn parse(value: &str) -> Option { + match value { + "nudity" => Some(Self::Nudity), + "malware" => Some(Self::Malware), + "profanity" => Some(Self::Profanity), + "illegal" => Some(Self::Illegal), + "spam" => Some(Self::Spam), + "impersonation" => Some(Self::Impersonation), + "other" => Some(Self::Other), + _ => None, + } + } +} + +/// One thing a report event objects to (`ReportTarget`, `src/utils/nip56.ts:25-29`). #[derive(Debug, Clone, PartialEq, Eq)] -pub struct Report { - pub reporter: Pubkey, +pub struct ReportTarget { pub reported_pubkey: Option, pub reported_event_id: Option, - pub report_type: String, - pub actionable: bool, + pub report_type: ReportType, +} + +/// How many targets one event may produce (`MAX_TARGETS_PER_EVENT`, +/// `src/utils/nip56.ts:38`). +pub const MAX_TARGETS_PER_EVENT: usize = 100; + +/// The targets of a report event (`extractReportTargets`, `src/utils/nip56.ts:49-102`). +pub fn extract_report_targets(tags: &[Tag]) -> Vec { + let pubkeys = hex64_tags(tags, "p"); + let event_ids = hex64_tags(tags, "e"); + + if pubkeys.is_empty() && event_ids.is_empty() { + return vec![ReportTarget { + reported_pubkey: None, + reported_event_id: None, + report_type: ReportType::Other, + }]; + } + + let mut targets = Vec::new(); + + if let (Some(pubkey), Some(event_id)) = (pubkeys.first(), event_ids.first()) { + let pubkey_type = report_type_of(pubkey.0); + let event_type = report_type_of(event_id.0); + + // Only a genuine disagreement — both sides carrying an explicit, different type — + // splits into two targets; one side omitting a type is not a conflict. + match (pubkey_type, event_type) { + (Some(pubkey_type), Some(event_type)) if pubkey_type != event_type => { + targets.push(ReportTarget { + reported_pubkey: None, + reported_event_id: Some(EventId::from_byte_array(event_id.1)), + report_type: event_type, + }); + targets.push(ReportTarget { + reported_pubkey: Some(Pubkey::from_byte_array(pubkey.1)), + reported_event_id: None, + report_type: pubkey_type, + }); + } + _ => targets.push(ReportTarget { + reported_pubkey: Some(Pubkey::from_byte_array(pubkey.1)), + reported_event_id: Some(EventId::from_byte_array(event_id.1)), + report_type: event_type.or(pubkey_type).unwrap_or(ReportType::Other), + }), + } + } else if let Some(event_id) = event_ids.first() { + targets.push(ReportTarget { + reported_pubkey: None, + reported_event_id: Some(EventId::from_byte_array(event_id.1)), + report_type: target_type_of(event_id.0), + }); + } else if let Some(pubkey) = pubkeys.first() { + targets.push(ReportTarget { + reported_pubkey: Some(Pubkey::from_byte_array(pubkey.1)), + reported_event_id: None, + report_type: target_type_of(pubkey.0), + }); + } + + for (tag, bytes) in pubkeys.iter().skip(1) { + targets.push(ReportTarget { + reported_pubkey: Some(Pubkey::from_byte_array(*bytes)), + reported_event_id: None, + report_type: target_type_of(tag), + }); + } + + for (tag, bytes) in event_ids.iter().skip(1) { + targets.push(ReportTarget { + reported_pubkey: None, + reported_event_id: Some(EventId::from_byte_array(*bytes)), + report_type: target_type_of(tag), + }); + } + + targets.truncate(MAX_TARGETS_PER_EVENT); + + targets +} + +/// Every `name`-tagged tag whose value is 64 hex characters, with the value's bytes. +fn hex64_tags<'a>(tags: &'a [Tag], name: &str) -> Vec<(&'a Tag, [u8; 32])> { + tags.iter() + .filter(|tag| tag.len() >= 2 && tag[0] == name) + .filter_map(|tag| hex64(&tag[1]).map(|bytes| (tag, bytes))) + .collect() +} + +/// The tag's declared report type, when it names one (`isValidReportType`). +fn report_type_of(tag: &Tag) -> Option { + tag.get(2).and_then(|value| ReportType::parse(value)) +} + +/// The tag's report type, with an absent or unknown one read as `Other` (`targetTypeOf`). +fn target_type_of(tag: &Tag) -> ReportType { + report_type_of(tag).unwrap_or(ReportType::Other) +} + +/// The 32 bytes of a 64-hex-character value, in either case (`HEX64_PATTERN`, +/// `src/utils/nip56.ts:16`). +fn hex64(value: &str) -> Option<[u8; 32]> { + if value.len() != 64 { + return None; + } + + let mut bytes = [0u8; 32]; + hex::decode_to_slice(value, &mut bytes).ok()?; + + Some(bytes) } -/// Parses a kind-1984 report into its target and type. -/// -/// Phase 1 stub — job 005; will parse the `p`/`e` tags and report type; see `src/utils/nip56.ts`. -pub fn parse_report(_event: &Event) -> Result { - Err(CoreError::NotImplemented { - module: "core::nip56::parse_report", - }) +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use super::*; + + // Hex with letters in it: an all-digit fixture makes `to_uppercase()` a no-op, and a + // test that uppercases it then asserts equality proves nothing. + fn pubkey_hex() -> String { + "3a".repeat(32) + } + + fn event_id_hex() -> String { + "4c".repeat(32) + } + + fn tag(parts: &[&str]) -> Tag { + parts.iter().map(|part| (*part).to_owned()).collect() + } + + #[test] + fn the_first_p_and_e_tags_collapse_into_one_target() { + let pubkey = pubkey_hex(); + let event_id = event_id_hex(); + let targets = + extract_report_targets(&[tag(&["p", &pubkey, "spam"]), tag(&["e", &event_id, "spam"])]); + + assert_eq!( + targets, + vec![ReportTarget { + reported_pubkey: Some(pubkey.parse().unwrap()), + reported_event_id: Some(event_id.parse().unwrap()), + report_type: ReportType::Spam, + }] + ); + } + + #[test] + fn a_type_disagreement_splits_into_two_targets() { + let pubkey = pubkey_hex(); + let event_id = event_id_hex(); + let targets = extract_report_targets(&[ + tag(&["p", &pubkey, "impersonation"]), + tag(&["e", &event_id, "nudity"]), + ]); + + assert_eq!( + targets, + vec![ + ReportTarget { + reported_pubkey: None, + reported_event_id: Some(event_id.parse().unwrap()), + report_type: ReportType::Nudity, + }, + ReportTarget { + reported_pubkey: Some(pubkey.parse().unwrap()), + reported_event_id: None, + report_type: ReportType::Impersonation, + }, + ] + ); + } + + #[test] + fn a_type_given_on_one_side_only_is_used_for_both_fields() { + let pubkey = pubkey_hex(); + let event_id = event_id_hex(); + let targets = + extract_report_targets(&[tag(&["p", &pubkey]), tag(&["e", &event_id, "malware"])]); + + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].report_type, ReportType::Malware); + } + + #[test] + fn extra_tags_are_single_field_targets() { + let pubkey = pubkey_hex(); + let event_id = event_id_hex(); + let other_pubkey = "5".repeat(64); + let targets = extract_report_targets(&[ + tag(&["p", &pubkey, "spam"]), + tag(&["p", &other_pubkey]), + tag(&["e", &event_id, "spam"]), + ]); + + assert_eq!(targets.len(), 2); + assert_eq!( + targets[1].reported_pubkey, + Some(other_pubkey.parse().unwrap()) + ); + assert_eq!(targets[1].reported_event_id, None); + assert_eq!(targets[1].report_type, ReportType::Other); + } + + #[test] + fn an_unknown_report_type_is_other() { + let pubkey = pubkey_hex(); + let targets = extract_report_targets(&[tag(&["p", &pubkey, "vibes"])]); + + assert_eq!(targets[0].report_type, ReportType::Other); + } + + #[test] + fn uppercase_hex_names_the_same_target() { + let pubkey = pubkey_hex(); + let upper = pubkey.to_uppercase(); + + assert_ne!( + upper, pubkey, + "the fixture must carry a letter, or this test proves nothing" + ); + + let targets = extract_report_targets(&[tag(&["p", &upper])]); + + assert_eq!(targets[0].reported_pubkey, Some(pubkey.parse().unwrap())); + } + + #[test] + fn a_malformed_or_short_tag_is_not_a_target() { + let not_hex = "g".repeat(64); + let targets = extract_report_targets(&[ + tag(&["p"]), + tag(&["p", "not-a-pubkey"]), + tag(&["e", ¬_hex]), + ]); + + assert_eq!( + targets, + vec![ReportTarget { + reported_pubkey: None, + reported_event_id: None, + report_type: ReportType::Other, + }] + ); + } + + #[test] + fn the_target_cap_drops_the_rest() { + let tags: Vec = (0..(MAX_TARGETS_PER_EVENT + 10)) + .map(|index| tag(&["p", &format!("{index:064x}")])) + .collect(); + + assert_eq!(extract_report_targets(&tags).len(), MAX_TARGETS_PER_EVENT); + } } diff --git a/crates/nostream-core/src/report_scoring.rs b/crates/nostream-core/src/report_scoring.rs index 9dcdeb12e..99dc8a3bd 100644 --- a/crates/nostream-core/src/report_scoring.rs +++ b/crates/nostream-core/src/report_scoring.rs @@ -1,4 +1,9 @@ //! NIP-56 report scoring. Mirrors `src/utils/report-scoring.ts`. +//! +//! A trusted moderator's report always carries maximum weight, independent of their distance +//! in the web-of-trust graph; everyone else decays by hop: a direct follow (distance 1) gets +//! full weight, each further hop halves it, and a pubkey outside the graph gets zero — stored, +//! but "doesn't trigger anything". /// The weight a report carries, derived from the reporter's WoT distance. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -11,14 +16,89 @@ pub enum ReportWeight { Unweighted, } -/// Scores a reporter against the relay's trust graph. -/// -/// Phase 1 stub — job 005; will score a kind-1984 report by WoT distance; see `src/utils/report-scoring.ts`. -pub fn score_reporter( - _distance: Option, - _is_trusted_moderator: bool, -) -> Result { - Err(crate::error::CoreError::NotImplemented { - module: "core::report_scoring::score_reporter", - }) +impl ReportWeight { + /// The weight the `reports.weight` column's `real` holds (`calculateReportWeight`, + /// `src/utils/report-scoring.ts:8-16`). + pub fn weight(self) -> f32 { + match self { + Self::Trusted => 1.0, + Self::Unweighted => 0.0, + // `2 ** -(distance - 1)`, and `distance <= 1` is the flat full weight. + Self::Weighted { distance } => { + let hops = distance.saturating_sub(1); + + // f32's smallest subnormal is 2^-149, so 2^-150 is the first exponent that + // lands on zero (Assumption 7 of the job's spec). Below this guard `powi` is + // left to round, which means a subnormal weight (distance 128-150) reads + // correctly in an optimised build and as zero in an unoptimised one; both are + // "stored, never actionable", and the `real` column holds either. + if hops >= 150 { + 0.0 + } else { + 2f32.powi(-(hops as i32)) + } + } + } + } +} + +/// Scores a reporter against the relay's trust graph (`calculateReportWeight`). +pub fn score_reporter(distance: Option, is_trusted_moderator: bool) -> ReportWeight { + if is_trusted_moderator { + return ReportWeight::Trusted; + } + + match distance { + None => ReportWeight::Unweighted, + Some(distance) => ReportWeight::Weighted { distance }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_trusted_moderator_is_trusted_whatever_the_distance() { + assert_eq!(score_reporter(None, true), ReportWeight::Trusted); + assert_eq!(score_reporter(Some(7), true), ReportWeight::Trusted); + assert_eq!(ReportWeight::Trusted.weight(), 1.0); + } + + #[test] + fn no_distance_is_unweighted() { + assert_eq!(score_reporter(None, false), ReportWeight::Unweighted); + assert_eq!(ReportWeight::Unweighted.weight(), 0.0); + } + + #[test] + fn a_direct_follow_weighs_one() { + assert_eq!( + score_reporter(Some(1), false), + ReportWeight::Weighted { distance: 1 } + ); + assert_eq!(score_reporter(Some(0), false).weight(), 1.0); + assert_eq!(score_reporter(Some(1), false).weight(), 1.0); + } + + #[test] + fn each_further_hop_halves_the_weight() { + let weights: Vec = (2..=5) + .map(|distance| score_reporter(Some(distance), false).weight()) + .collect(); + + assert_eq!(weights, vec![0.5, 0.25, 0.125, 0.0625]); + } + + #[test] + fn a_very_large_distance_underflows_to_zero() { + // The weight is `2^-(distance - 1)`, so distance 150 is 2^-149: f32's smallest + // subnormal, which is representable, and the first exponent that is not is 2^-150 — + // distance 151, where `weight` answers an exact zero. Distance 150 is asserted as a + // bound rather than a value because `powi` flushes subnormals to zero in an + // unoptimised build and rounds them correctly in an optimised one. + assert!(score_reporter(Some(150), false).weight() <= f32::MIN_POSITIVE); + assert_eq!(score_reporter(Some(151), false).weight(), 0.0); + assert_eq!(score_reporter(Some(u32::MAX), false).weight(), 0.0); + } } diff --git a/crates/nostream-relay/Cargo.toml b/crates/nostream-relay/Cargo.toml index 122360419..3dd1371fb 100644 --- a/crates/nostream-relay/Cargo.toml +++ b/crates/nostream-relay/Cargo.toml @@ -22,5 +22,8 @@ tokio.workspace = true tokio-tungstenite.workspace = true tracing.workspace = true +[dev-dependencies] +base64.workspace = true + [lints] workspace = true diff --git a/crates/nostream-relay/src/handlers/event.rs b/crates/nostream-relay/src/handlers/event.rs index 01ce64c34..b12c29c06 100644 --- a/crates/nostream-relay/src/handlers/event.rs +++ b/crates/nostream-relay/src/handlers/event.rs @@ -151,7 +151,7 @@ pub async fn handle(event: Event, context: &HandlerContext) { if let Err(error) = Strategy.execute(&event, context).await { match &error { RelayError::NotImplemented { module } => { - tracing::debug!(module, "strategy is not implemented in this job"); + tracing::debug!(module, "strategy is not implemented"); } other => tracing::error!(error = %other, "event strategy failed"), } diff --git a/crates/nostream-relay/src/handlers/mod.rs b/crates/nostream-relay/src/handlers/mod.rs index 72bc801ab..5b6a3cb44 100644 --- a/crates/nostream-relay/src/handlers/mod.rs +++ b/crates/nostream-relay/src/handlers/mod.rs @@ -35,9 +35,11 @@ use crate::hidden_content::HiddenContentCache; use crate::limits::RateLimits; use crate::relay_key::RelayKeySource; use crate::sink::FrameSink; +use crate::wot::WotGraph; /// 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. +/// in-memory doubles and no handler knows which backend it has. Storage only: a service the +/// relay owns lives on the context beside `limits`. pub struct Stores { pub events: Arc, pub users: Arc, @@ -60,6 +62,9 @@ pub struct HandlerContext { pub sink: FrameSink, pub connection: Arc, pub stores: Arc, + /// The trust graph `contact_list` and `report` reach for, best-effort. A service the relay + /// owns rather than a store (`wot.rs`), held here beside `limits`. + pub wot: Arc, pub limits: Arc, pub hidden: Arc, pub keys: Arc, diff --git a/crates/nostream-relay/src/lib.rs b/crates/nostream-relay/src/lib.rs index 2c17dfd61..54de57778 100644 --- a/crates/nostream-relay/src/lib.rs +++ b/crates/nostream-relay/src/lib.rs @@ -20,18 +20,19 @@ //! **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` +//! five handlers, the strategy trait with its ordered registry and all fifteen strategies, +//! the web-of-trust seam those strategies reach for (`wot.rs`), 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. //! -//! **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, 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. +//! **Still stubbed:** `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, +//! 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 +//! crate 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`). @@ -54,6 +55,7 @@ pub mod strategies; pub mod testing; pub mod transport; pub mod unavailable; +pub mod wot; pub use error::RelayError; pub use relay::{Relay, RelayConfig}; diff --git a/crates/nostream-relay/src/relay.rs b/crates/nostream-relay/src/relay.rs index 4d15a8356..003bd6892 100644 --- a/crates/nostream-relay/src/relay.rs +++ b/crates/nostream-relay/src/relay.rs @@ -32,6 +32,7 @@ use crate::limits::RateLimits; use crate::relay_key::RelayKeySource; use crate::sink::FrameSink; use crate::transport; +use crate::wot::WotGraph; /// The knobs a [`Relay`] is built with that are not settings: a constant the capture fixes in /// code (`liveness`, the NIP-56 refresh) or a policy the port adds with no counterpart there @@ -81,6 +82,7 @@ impl Default for RelayConfig { pub struct Relay { settings: Arc, stores: Arc, + wot: Arc, clock: Arc, keys: Arc, limits: Arc, @@ -102,8 +104,8 @@ impl std::fmt::Debug for Relay { } impl Relay { - /// Builds the relay from its collaborators: the settings, the stores, the clock, the key - /// source, the randomness and the intervals. + /// Builds the relay from its collaborators: the settings, the stores, the trust graph, 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 @@ -113,6 +115,7 @@ impl Relay { pub fn new( settings: Settings, stores: Arc, + wot: Arc, clock: Arc, keys: RelayKeySource, random: Arc, @@ -126,6 +129,7 @@ impl Relay { random, settings, stores, + wot, clock, keys: Arc::new(keys), hidden: HiddenContentCache::new(), @@ -150,6 +154,7 @@ impl Relay { sink, connection, stores: Arc::clone(&self.stores), + wot: Arc::clone(&self.wot), limits: Arc::clone(&self.limits), hidden: Arc::clone(&self.hidden), keys: Arc::clone(&self.keys), diff --git a/crates/nostream-relay/src/strategies/contact_list.rs b/crates/nostream-relay/src/strategies/contact_list.rs index f5b03d8ae..077989b43 100644 --- a/crates/nostream-relay/src/strategies/contact_list.rs +++ b/crates/nostream-relay/src/strategies/contact_list.rs @@ -1,29 +1,83 @@ -//! The `contact-list` event strategy. Mirrors `src/handlers/event-strategies/contact-list-event-strategy.ts`. +//! The `contact-list` event strategy. Mirrors +//! `src/handlers/event-strategies/contact-list-event-strategy.ts`. +//! +//! Kind 3 is both replaceable and a contact list, so the selector intercepts it before the +//! replaceable arm and this one adds the NIP-02 side effect: after a `p`-tag refresh of the +//! author's follows in the trust graph, best-effort. The list is stored and broadcast before the +//! graph is touched, so a graph failure never rejects a valid event. +//! +//! A failed upsert is `rejected: event already exists` when the event id already exists and the +//! handler's generic failure otherwise — the store's own text is never forwarded (ADR 0006). use std::future::Future; use std::pin::Pin; +use std::sync::Arc; use nostream_core::Event; +use nostream_storage::StorageError; use crate::error::RelayError; use crate::handlers::HandlerContext; -use crate::strategies::EventStrategy; +use crate::strategies::{EventStrategy, event_record}; /// The `contact_list` arm. #[derive(Debug, Clone, Copy, Default)] pub struct ContactListStrategy; 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, + event: &'a Event, + context: &'a HandlerContext, ) -> Pin> + Send + 'a>> { - Box::pin(async { - Err(RelayError::NotImplemented { - module: "relay::strategies::contact_list", - }) + Box::pin(async move { + let record = event_record(event, context); + + let count = match context.stores.events.upsert(&record).await { + Ok(count) => count, + Err(StorageError::DuplicateEvent) => { + context.send_ok(event.id, false, "rejected: event already exists", None); + + return Ok(()); + } + Err(error) => return Err(error.into()), + }; + + context.send_ok( + event.id, + true, + if count > 0 { "" } else { "duplicate:" }, + None, + ); + + if count == 0 { + return Ok(()); + } + + let _ = context.broadcast.send(Arc::new(event.clone())); + + // A `p` tag with no value names no follow; the capture forwards `undefined` and + // leaves it to the graph service to reject, and this skips it instead. + let follows: Vec = event + .tags + .iter() + .filter(|tag| tag[0] == "p") + .filter_map(|tag| tag.get(1).cloned()) + .collect(); + + if let Err(error) = context + .wot + .update_follow_list(&event.pubkey, &follows) + .await + { + tracing::error!( + error = %error, + pubkey = %event.pubkey, + "unable to update wot graph" + ); + } + + Ok(()) }) } } diff --git a/crates/nostream-relay/src/strategies/delete.rs b/crates/nostream-relay/src/strategies/delete.rs index 3ec0ca26f..af6604dab 100644 --- a/crates/nostream-relay/src/strategies/delete.rs +++ b/crates/nostream-relay/src/strategies/delete.rs @@ -1,29 +1,61 @@ //! The `delete` event strategy. Mirrors `src/handlers/event-strategies/delete-event-strategy.ts`. +//! +//! A NIP-09 request: every `e` tag whose value parses as an event id is soft-deleted — exact +//! 32 bytes of lowercase hex, which is what `EventId`'s own parser accepts — **before** the +//! request itself is stored, so a client that re-reads after the `OK` sees the deletions +//! already applied. Then the common create-or-dedupe. use std::future::Future; use std::pin::Pin; +use std::sync::Arc; -use nostream_core::Event; +use nostream_core::{Event, EventId}; use crate::error::RelayError; use crate::handlers::HandlerContext; -use crate::strategies::EventStrategy; +use crate::strategies::{EventStrategy, event_record}; /// The `delete` arm. #[derive(Debug, Clone, Copy, Default)] pub struct DeleteStrategy; 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, + event: &'a Event, + context: &'a HandlerContext, ) -> Pin> + Send + 'a>> { - Box::pin(async { - Err(RelayError::NotImplemented { - module: "relay::strategies::delete", - }) + Box::pin(async move { + let ids: Vec = event + .tags + .iter() + .filter(|tag| tag.len() >= 2 && tag[0] == "e") + .filter_map(|tag| tag[1].parse::().ok()) + .collect(); + + if !ids.is_empty() { + context + .stores + .events + .delete_by_pubkey_and_ids(&event.pubkey, &ids) + .await?; + } + + let record = event_record(event, context); + let count = context.stores.events.create(&record).await?; + + context.send_ok( + event.id, + true, + if count > 0 { "" } else { "duplicate:" }, + None, + ); + + if count > 0 { + let _ = context.broadcast.send(Arc::new(event.clone())); + } + + Ok(()) }) } } diff --git a/crates/nostream-relay/src/strategies/dvm_job_request.rs b/crates/nostream-relay/src/strategies/dvm_job_request.rs index 440feaab0..a91cebf59 100644 --- a/crates/nostream-relay/src/strategies/dvm_job_request.rs +++ b/crates/nostream-relay/src/strategies/dvm_job_request.rs @@ -1,29 +1,57 @@ -//! The `dvm-job-request` event strategy. Mirrors `src/handlers/event-strategies/dvm-job-request-event-strategy.ts`. +//! The `dvm-job-request` event strategy. Mirrors +//! `src/handlers/event-strategies/dvm-job-request-event-strategy.ts`. +//! +//! A NIP-90 job request is stored and broadcast like any other event, and then the job row is +//! written so a worker can pick it up. That second write is best-effort: the event is already +//! stored and broadcast correctly, so a repository failure there must not reject a valid request. use std::future::Future; use std::pin::Pin; +use std::sync::Arc; use nostream_core::Event; use crate::error::RelayError; use crate::handlers::HandlerContext; -use crate::strategies::EventStrategy; +use crate::strategies::{EventStrategy, event_record}; /// The `dvm_job_request` arm. #[derive(Debug, Clone, Copy, Default)] pub struct DvmJobRequestStrategy; 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, + event: &'a Event, + context: &'a HandlerContext, ) -> Pin> + Send + 'a>> { - Box::pin(async { - Err(RelayError::NotImplemented { - module: "relay::strategies::dvm_job_request", - }) + Box::pin(async move { + let record = event_record(event, context); + let count = context.stores.events.create(&record).await?; + + context.send_ok( + event.id, + true, + if count > 0 { "" } else { "duplicate:" }, + None, + ); + + if count == 0 { + return Ok(()); + } + + let _ = context.broadcast.send(Arc::new(event.clone())); + + if let Err(error) = context + .stores + .dvm_jobs + .create(&event.id, &event.pubkey, event.kind) + .await + { + tracing::error!(error = %error, event = %event.id, "unable to record dvm job"); + } + + Ok(()) }) } } diff --git a/crates/nostream-relay/src/strategies/ephemeral.rs b/crates/nostream-relay/src/strategies/ephemeral.rs index 819d776f1..d58d9f647 100644 --- a/crates/nostream-relay/src/strategies/ephemeral.rs +++ b/crates/nostream-relay/src/strategies/ephemeral.rs @@ -1,7 +1,11 @@ //! The `ephemeral` event strategy. Mirrors `src/handlers/event-strategies/ephemeral-event-strategy.ts`. +//! +//! Ephemeral kinds are relayed and never stored: `["OK",,true,""]` and then a broadcast, with +//! no repository call at all. use std::future::Future; use std::pin::Pin; +use std::sync::Arc; use nostream_core::Event; @@ -9,21 +13,23 @@ use crate::error::RelayError; use crate::handlers::HandlerContext; use crate::strategies::EventStrategy; -/// The `ephemeral` arm. +/// The `ephemeral` arm: kinds 20000..=29999 that no other arm claims. #[derive(Debug, Clone, Copy, Default)] pub struct EphemeralStrategy; 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, + event: &'a Event, + context: &'a HandlerContext, ) -> Pin> + Send + 'a>> { - Box::pin(async { - Err(RelayError::NotImplemented { - module: "relay::strategies::ephemeral", - }) + Box::pin(async move { + context.send_ok(event.id, true, "", None); + + // A closed hub means no connection is listening; the event was still relayed. + let _ = context.broadcast.send(Arc::new(event.clone())); + + Ok(()) }) } } diff --git a/crates/nostream-relay/src/strategies/gift_wrap.rs b/crates/nostream-relay/src/strategies/gift_wrap.rs index b58b6d42c..f3fa2d65a 100644 --- a/crates/nostream-relay/src/strategies/gift_wrap.rs +++ b/crates/nostream-relay/src/strategies/gift_wrap.rs @@ -1,29 +1,84 @@ -//! The `gift-wrap` event strategy. Mirrors `src/handlers/event-strategies/gift-wrap-event-strategy.ts`. +//! The `gift-wrap` event strategy. Mirrors +//! `src/handlers/event-strategies/gift-wrap-event-strategy.ts`. +//! +//! A NIP-17 gift wrap is validated before it is stored — one `p` tag naming a 64-character +//! lowercase hex recipient, and content that is a structurally valid NIP-44 v2 payload — then +//! `EventRepository::create`, `["OK",,true,""]` or `"duplicate:"`, and a broadcast only on +//! insert. A refusal is `["OK",,false,"invalid: "]` and stores nothing. use std::future::Future; use std::pin::Pin; +use std::sync::Arc; -use nostream_core::Event; +use nostream_core::nip44::validate_payload; +use nostream_core::{Event, Pubkey}; use crate::error::RelayError; use crate::handlers::HandlerContext; -use crate::strategies::EventStrategy; +use crate::strategies::{EventStrategy, event_record}; /// The `gift_wrap` arm. #[derive(Debug, Clone, Copy, Default)] pub struct GiftWrapStrategy; 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, + event: &'a Event, + context: &'a HandlerContext, ) -> Pin> + Send + 'a>> { - Box::pin(async { - Err(RelayError::NotImplemented { - module: "relay::strategies::gift_wrap", - }) + Box::pin(async move { + if let Some(reason) = validate_gift_wrap(event) { + context.send_ok(event.id, false, &format!("invalid: {reason}"), None); + + return Ok(()); + } + + let record = event_record(event, context); + let count = context.stores.events.create(&record).await?; + + context.send_ok( + event.id, + true, + if count > 0 { "" } else { "duplicate:" }, + None, + ); + + if count > 0 { + let _ = context.broadcast.send(Arc::new(event.clone())); + } + + Ok(()) }) } } + +/// `validateGiftWrap` (`gift-wrap-event-strategy.ts:36-60`): NIP-17 allows exactly one +/// recipient per wrap, and the payload must look like a NIP-44 v2 message. +fn validate_gift_wrap(event: &Event) -> Option { + let recipients: Vec<&Vec> = event + .tags + .iter() + .filter(|tag| tag.len() >= 2 && tag[0] == "p") + .collect(); + + if recipients.is_empty() { + return Some( + "gift wrap event (kind 1059) must have a p tag identifying the recipient".to_owned(), + ); + } + + if recipients.len() > 1 { + return Some("gift wrap event (kind 1059) must have exactly one p tag".to_owned()); + } + + if recipients[0][1].parse::().is_err() { + return Some( + "gift wrap event (kind 1059) p tag must contain a valid 64-character lowercase hex pubkey" + .to_owned(), + ); + } + + validate_payload(&event.content) + .map(|reason| format!("gift wrap content must be a valid NIP-44 v2 payload: {reason}")) +} diff --git a/crates/nostream-relay/src/strategies/group.rs b/crates/nostream-relay/src/strategies/group.rs index bd6e4cd65..c2ae0f1b1 100644 --- a/crates/nostream-relay/src/strategies/group.rs +++ b/crates/nostream-relay/src/strategies/group.rs @@ -1,29 +1,79 @@ //! The `group` event strategy. Mirrors `src/handlers/event-strategies/group-event-strategy.ts`. +//! +//! MIP-03: a kind-445 group event must carry exactly one `h` tag whose value is the 64-character +//! lowercase hex `nostr_group_id` from the Marmot Group Data Extension — otherwise an `#h` +//! subscription silently matches the wrong events. After that it is the common +//! create-or-dedupe: `EventRepository::create`, `["OK",,true,""]` or `"duplicate:"`, and a +//! broadcast only on insert. use std::future::Future; use std::pin::Pin; +use std::sync::Arc; use nostream_core::Event; use crate::error::RelayError; use crate::handlers::HandlerContext; -use crate::strategies::EventStrategy; +use crate::strategies::{EventStrategy, event_record, is_lowercase_hex64}; /// The `group` arm. #[derive(Debug, Clone, Copy, Default)] pub struct GroupStrategy; 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, + event: &'a Event, + context: &'a HandlerContext, ) -> Pin> + Send + 'a>> { - Box::pin(async { - Err(RelayError::NotImplemented { - module: "relay::strategies::group", - }) + Box::pin(async move { + if let Some(reason) = validate_group_event(event) { + context.send_ok(event.id, false, &format!("invalid: {reason}"), None); + + return Ok(()); + } + + let record = event_record(event, context); + let count = context.stores.events.create(&record).await?; + + context.send_ok( + event.id, + true, + if count > 0 { "" } else { "duplicate:" }, + None, + ); + + if count > 0 { + let _ = context.broadcast.send(Arc::new(event.clone())); + } + + Ok(()) }) } } + +/// `validateGroupEvent` (`group-event-strategy.ts:38-55`). +fn validate_group_event(event: &Event) -> Option { + let groups: Vec<&Vec> = event + .tags + .iter() + .filter(|tag| tag.len() >= 2 && tag[0] == "h") + .collect(); + + if groups.is_empty() { + return Some("group event (kind 445) must have an h tag identifying the group".to_owned()); + } + + if groups.len() > 1 { + return Some("group event (kind 445) must have exactly one h tag".to_owned()); + } + + if !is_lowercase_hex64(&groups[0][1]) { + return Some( + "group event (kind 445) h tag must contain a valid 64-character lowercase hex group id" + .to_owned(), + ); + } + + None +} diff --git a/crates/nostream-relay/src/strategies/invite_request.rs b/crates/nostream-relay/src/strategies/invite_request.rs index f9162cd0e..95f0dc84d 100644 --- a/crates/nostream-relay/src/strategies/invite_request.rs +++ b/crates/nostream-relay/src/strategies/invite_request.rs @@ -1,4 +1,11 @@ -//! The `invite-request` event strategy. Mirrors `src/handlers/event-strategies/invite-request-event-strategy.ts`. +//! The `invite-request` event strategy. Mirrors +//! `src/handlers/event-strategies/invite-request-event-strategy.ts`. +//! +//! NIP-43 kind 28935 travels relay → client only: a client asks for an invite with a `REQ`, and +//! the relay answers with an event signed by the pubkey in `self`. A client-published 28935 is +//! therefore always refused, and it is refused *before* the ephemeral arm sees it — the kind sits +//! in the ephemeral range, and the clients subscribed to 28935 are exactly the ones waiting for a +//! real invite, so a broadcast here would hand anyone's forged claim tag to them. use std::future::Future; use std::pin::Pin; @@ -14,16 +21,25 @@ use crate::strategies::EventStrategy; pub struct InviteRequestStrategy; 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, + event: &'a Event, + context: &'a HandlerContext, ) -> Pin> + Send + 'a>> { - Box::pin(async { - Err(RelayError::NotImplemented { - module: "relay::strategies::invite_request", - }) + Box::pin(async move { + tracing::debug!( + pubkey = %event.pubkey, + "rejecting client-published invite request" + ); + + context.send_ok( + event.id, + false, + "invalid: kind 28935 is issued by the relay, request one with a REQ for kind 28935", + None, + ); + + Ok(()) }) } } diff --git a/crates/nostream-relay/src/strategies/join_request.rs b/crates/nostream-relay/src/strategies/join_request.rs index ac02f3512..d746bf52e 100644 --- a/crates/nostream-relay/src/strategies/join_request.rs +++ b/crates/nostream-relay/src/strategies/join_request.rs @@ -1,29 +1,138 @@ -//! The `join-request` event strategy. Mirrors `src/handlers/event-strategies/join-request-event-strategy.ts`. +//! The `join-request` event strategy. Mirrors +//! `src/handlers/event-strategies/join-request-event-strategy.ts`. +//! +//! NIP-43 membership, in the capture's order: the relay must have NIP-43 enabled, the request's +//! `created_at` must be within ten minutes of the relay's clock, the socket must be +//! authenticated as the requester, the event must carry a `claim` tag, the requester must not +//! already be a member, and the invite code must still be claimable. Only then is the user +//! admitted. +//! +//! The request is answered with an `OK` result and never broadcast: the claim tag is a bearer +//! secret, and anyone subscribed to kind 28934 would read it. use std::future::Future; use std::pin::Pin; +use chrono::{DateTime, Utc}; use nostream_core::Event; +use nostream_core::nip43::{get_claim_tag, is_request_timestamp_valid}; use crate::error::RelayError; use crate::handlers::HandlerContext; use crate::strategies::EventStrategy; -/// The `join_request` arm. +/// The `join_request` arm: kind 28934. #[derive(Debug, Clone, Copy, Default)] pub struct JoinRequestStrategy; 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, + event: &'a Event, + context: &'a HandlerContext, ) -> Pin> + Send + 'a>> { - Box::pin(async { - Err(RelayError::NotImplemented { - module: "relay::strategies::join_request", - }) + Box::pin(async move { + if !context + .settings + .nip43 + .as_ref() + .is_some_and(|nip43| nip43.enabled) + { + context.send_ok( + event.id, + false, + "restricted: NIP-43 is not enabled on this relay", + None, + ); + + return Ok(()); + } + + if !is_request_timestamp_valid(event.created_at, context.now_secs()) { + context.send_ok( + event.id, + false, + "invalid: created_at is too far from the current time", + None, + ); + + return Ok(()); + } + + if !context.authenticated_pubkeys().contains(&event.pubkey) { + context.send_ok( + event.id, + false, + "auth-required: authentication required (NIP-42)", + None, + ); + + return Ok(()); + } + + let Some(claim_code) = get_claim_tag(event) else { + context.send_ok( + event.id, + false, + "invalid: join request requires a claim tag", + None, + ); + + return Ok(()); + }; + + let existing = context.stores.users.find_by_pubkey(&event.pubkey).await?; + + if existing.is_some_and(|user| user.is_admitted) { + context.send_ok( + event.id, + true, + "duplicate: you are already a member of this relay", + None, + ); + + return Ok(()); + } + + if !context + .stores + .invites + .claim_code(claim_code, &event.pubkey) + .await? + { + context.send_ok( + event.id, + false, + "restricted: invalid or expired invite code", + None, + ); + + return Ok(()); + } + + // Not transactional with the claim: if the admission fails, one code use is wasted. + // The other order could admit a member without a valid code. + context + .stores + .users + .admit_user(&event.pubkey, admitted_at(context)) + .await?; + + context.send_ok( + event.id, + true, + &format!("info: welcome to {}!", context.relay_url()), + None, + ); + + Ok(()) }) } } + +/// The instant this request is admitted at, from the relay's clock (`admitUser(pubkey, new +/// Date())`). A second count outside chrono's range cannot come from a clock the relay serves +/// with; it reads as the epoch. +fn admitted_at(context: &HandlerContext) -> DateTime { + DateTime::from_timestamp(context.now_secs(), 0).unwrap_or(DateTime::UNIX_EPOCH) +} diff --git a/crates/nostream-relay/src/strategies/leave_request.rs b/crates/nostream-relay/src/strategies/leave_request.rs index 1187485cc..fc9f0ed43 100644 --- a/crates/nostream-relay/src/strategies/leave_request.rs +++ b/crates/nostream-relay/src/strategies/leave_request.rs @@ -1,29 +1,98 @@ -//! The `leave-request` event strategy. Mirrors `src/handlers/event-strategies/leave-request-event-strategy.ts`. +//! The `leave-request` event strategy. Mirrors +//! `src/handlers/event-strategies/leave-request-event-strategy.ts`. +//! +//! NIP-43 membership departure, in the capture's order: the relay must have NIP-43 enabled, the +//! request must carry the NIP-70 `-` tag, its `created_at` must be within ten minutes of the +//! relay's clock, the socket must be authenticated as the requester, and only a member who is +//! currently admitted is revoked. A non-member is answered `["OK",,true,""]` without a write, +//! which keeps the arm from creating a phantom user row. +//! +//! The `-` tag is checked **before** the timestamp — the capture's order, and the reverse of the +//! join arm's, which has no `-` check at all. The request is never broadcast. use std::future::Future; use std::pin::Pin; use nostream_core::Event; +use nostream_core::nip43::is_request_timestamp_valid; use crate::error::RelayError; use crate::handlers::HandlerContext; use crate::strategies::EventStrategy; -/// The `leave_request` arm. +/// The `leave_request` arm: kind 28936. #[derive(Debug, Clone, Copy, Default)] pub struct LeaveRequestStrategy; 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, + event: &'a Event, + context: &'a HandlerContext, ) -> Pin> + Send + 'a>> { - Box::pin(async { - Err(RelayError::NotImplemented { - module: "relay::strategies::leave_request", - }) + Box::pin(async move { + if !context + .settings + .nip43 + .as_ref() + .is_some_and(|nip43| nip43.enabled) + { + context.send_ok( + event.id, + false, + "restricted: NIP-43 is not enabled on this relay", + None, + ); + + return Ok(()); + } + + if !event.is_protected() { + context.send_ok( + event.id, + false, + "invalid: leave request requires a NIP-70 \"-\" tag", + None, + ); + + return Ok(()); + } + + if !is_request_timestamp_valid(event.created_at, context.now_secs()) { + context.send_ok( + event.id, + false, + "invalid: created_at is too far from the current time", + None, + ); + + return Ok(()); + } + + if !context.authenticated_pubkeys().contains(&event.pubkey) { + context.send_ok( + event.id, + false, + "auth-required: authentication required (NIP-42)", + None, + ); + + return Ok(()); + } + + let existing = context.stores.users.find_by_pubkey(&event.pubkey).await?; + + if !existing.is_some_and(|user| user.is_admitted) { + context.send_ok(event.id, true, "", None); + + return Ok(()); + } + + context.stores.users.revoke_admission(&event.pubkey).await?; + + context.send_ok(event.id, true, "", None); + + Ok(()) }) } } diff --git a/crates/nostream-relay/src/strategies/mod.rs b/crates/nostream-relay/src/strategies/mod.rs index 1986d50f9..3e7a43c63 100644 --- a/crates/nostream-relay/src/strategies/mod.rs +++ b/crates/nostream-relay/src/strategies/mod.rs @@ -2,11 +2,10 @@ //! selection chain of `src/factories/event-strategy-factory.ts:56-108`. //! //! 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. +//! method, `(event, context)` — and every arm has that shape, so behaviour is added to a type +//! rather than a second convention chosen (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. All fifteen arms are implemented here. pub mod contact_list; pub mod default; @@ -27,7 +26,8 @@ pub mod vanish; use std::future::Future; use std::pin::Pin; -use nostream_core::{Event, Kind}; +use nostream_core::{Event, Kind, expiration}; +use nostream_storage::repositories::EventRecord; use crate::error::RelayError; use crate::handlers::HandlerContext; @@ -168,11 +168,29 @@ impl EventStrategy for Strategy { } } -#[cfg(test)] -mod stubs { - //! AC27: each of the fourteen arms is honestly stubbed. +/// The row an arm writes: the event, its NIP-40 expiration and the socket's address. +fn event_record(event: &Event, context: &HandlerContext) -> EventRecord { + let mut record = EventRecord::from_event(event); + record.expires_at = expiration::get_event_expiration(event).map(i64::from); + record.remote_address = context.connection.remote_address.parse().ok(); - #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + record +} + +/// Whether a tag value is exactly 32 bytes of lowercase hex — the `group` arm's rule for its +/// `h` tag. It is a predicate rather than a parse because a group id has no core type to parse +/// into; everywhere a value is an event id or a pubkey, `EventId`/`Pubkey`'s own parser decides +/// it. The report arm deliberately accepts either case (`nip56::extract_report_targets`); ADR +/// 0006 records why. +fn is_lowercase_hex64(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +#[cfg(test)] +mod tests { use super::*; use nostream_core::event::{EventId, Pubkey, Signature}; @@ -225,132 +243,4 @@ mod stubs { 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::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::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::ParameterizedReplaceable => { - parameterized_replaceable::ParameterizedReplaceableStrategy - .execute(&event_of(30000), &context) - .await - } - Selection::Default => unreachable!("default is implemented"), - }; - - match outcome { - Err(RelayError::NotImplemented { module: reported }) => { - assert_eq!(reported, module, "{selection:?}"); - } - // Anything but `NotImplemented` means the arm was wired without a - // port: an `assert`, 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/strategies/parameterized_replaceable.rs b/crates/nostream-relay/src/strategies/parameterized_replaceable.rs index e30cdf996..512891d79 100644 --- a/crates/nostream-relay/src/strategies/parameterized_replaceable.rs +++ b/crates/nostream-relay/src/strategies/parameterized_replaceable.rs @@ -1,29 +1,56 @@ -//! The `parameterized-replaceable` event strategy. Mirrors `src/handlers/event-strategies/parameterized-replaceable-event-strategy.ts`. +//! The `parameterized-replaceable` event strategy. Mirrors +//! `src/handlers/event-strategies/parameterized-replaceable-event-strategy.ts`. +//! +//! Addressable kinds (30000..=39999) are replaced per `d` tag, so the first `d` tag's value +//! becomes the row's deduplication key — `[""]` when the event carries none, which is a key of +//! its own rather than the default `[pubkey, kind]`. Then the replaceable four steps: `upsert`, +//! `["OK",,true,""]` or `"duplicate:"`, and a broadcast only when a row was written. A store +//! failure escapes to the handler; the capture has no catch in this arm either. use std::future::Future; use std::pin::Pin; +use std::sync::Arc; use nostream_core::Event; use crate::error::RelayError; use crate::handlers::HandlerContext; -use crate::strategies::EventStrategy; +use crate::strategies::{EventStrategy, event_record}; /// The `parameterized_replaceable` arm. #[derive(Debug, Clone, Copy, Default)] pub struct ParameterizedReplaceableStrategy; 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, + event: &'a Event, + context: &'a HandlerContext, ) -> Pin> + Send + 'a>> { - Box::pin(async { - Err(RelayError::NotImplemented { - module: "relay::strategies::parameterized_replaceable", - }) + Box::pin(async move { + let deduplication = event + .tags + .iter() + .find(|tag| tag.len() >= 2 && tag[0] == "d") + .map(|tag| tag[1].clone()) + .unwrap_or_default(); + + let record = event_record(event, context) + .with_deduplication(vec![serde_json::Value::String(deduplication)]); + let count = context.stores.events.upsert(&record).await?; + + context.send_ok( + event.id, + true, + if count > 0 { "" } else { "duplicate:" }, + None, + ); + + if count > 0 { + let _ = context.broadcast.send(Arc::new(event.clone())); + } + + Ok(()) }) } } diff --git a/crates/nostream-relay/src/strategies/replaceable.rs b/crates/nostream-relay/src/strategies/replaceable.rs index 45ef31f52..9407cbeea 100644 --- a/crates/nostream-relay/src/strategies/replaceable.rs +++ b/crates/nostream-relay/src/strategies/replaceable.rs @@ -1,29 +1,57 @@ -//! The `replaceable` event strategy. Mirrors `src/handlers/event-strategies/replaceable-event-strategy.ts`. +//! The `replaceable` event strategy. Mirrors +//! `src/handlers/event-strategies/replaceable-event-strategy.ts`. +//! +//! `EventRepository::upsert` applies the precedence rule in storage — the incoming row wins when +//! it is newer, or equal and the smaller id — so this arm only reads the answer: `["OK",,true,""]` +//! when a row was written, `"duplicate:"` when the stored row won, and a broadcast only for the +//! first. A failed upsert is `rejected: event already exists` when the event id already exists +//! and the handler's generic failure otherwise: the store's own text is never forwarded to a +//! client (ADR 0006). use std::future::Future; use std::pin::Pin; +use std::sync::Arc; use nostream_core::Event; +use nostream_storage::StorageError; use crate::error::RelayError; use crate::handlers::HandlerContext; -use crate::strategies::EventStrategy; +use crate::strategies::{EventStrategy, event_record}; -/// The `replaceable` arm. +/// The `replaceable` arm: kinds 0, 3, 41 and 10000..=19999. #[derive(Debug, Clone, Copy, Default)] pub struct ReplaceableStrategy; 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, + event: &'a Event, + context: &'a HandlerContext, ) -> Pin> + Send + 'a>> { - Box::pin(async { - Err(RelayError::NotImplemented { - module: "relay::strategies::replaceable", - }) + Box::pin(async move { + let record = event_record(event, context); + + match context.stores.events.upsert(&record).await { + Ok(count) => { + context.send_ok( + event.id, + true, + if count > 0 { "" } else { "duplicate:" }, + None, + ); + + if count > 0 { + let _ = context.broadcast.send(Arc::new(event.clone())); + } + } + Err(StorageError::DuplicateEvent) => { + context.send_ok(event.id, false, "rejected: event already exists", None); + } + Err(error) => return Err(error.into()), + } + + Ok(()) }) } } diff --git a/crates/nostream-relay/src/strategies/report.rs b/crates/nostream-relay/src/strategies/report.rs index 4c1a9e538..fb8f3dc83 100644 --- a/crates/nostream-relay/src/strategies/report.rs +++ b/crates/nostream-relay/src/strategies/report.rs @@ -1,29 +1,121 @@ //! The `report` event strategy. Mirrors `src/handlers/event-strategies/report-event-strategy.ts`. +//! +//! A NIP-56 report is stored and broadcast like any other event; everything after that is one +//! best-effort block. When `nip56` is on, the reporter is scored — a trusted moderator at full +//! weight without consulting the graph at all, everyone else by web-of-trust distance — one row +//! is written per extracted target in a single batch, and a target a moderator's report makes +//! actionable is marked in the hidden-content mirror so existing subscribers stop receiving it. +//! A failure anywhere in that block never rejects the report. use std::future::Future; use std::pin::Pin; +use std::sync::Arc; use nostream_core::Event; +use nostream_core::nip56::extract_report_targets; +use nostream_core::report_scoring::score_reporter; +use nostream_storage::repositories::{ActionableTarget, NewReport}; use crate::error::RelayError; use crate::handlers::HandlerContext; -use crate::strategies::EventStrategy; +use crate::strategies::{EventStrategy, event_record}; -/// The `report` arm. +/// The `report` arm: kind 1984. #[derive(Debug, Clone, Copy, Default)] pub struct ReportStrategy; 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, + event: &'a Event, + context: &'a HandlerContext, ) -> Pin> + Send + 'a>> { - Box::pin(async { - Err(RelayError::NotImplemented { - module: "relay::strategies::report", - }) + Box::pin(async move { + let record = event_record(event, context); + let count = context.stores.events.create(&record).await?; + + context.send_ok( + event.id, + true, + if count > 0 { "" } else { "duplicate:" }, + None, + ); + + if count == 0 { + return Ok(()); + } + + let _ = context.broadcast.send(Arc::new(event.clone())); + + if let Err(error) = record_report(event, context).await { + tracing::error!( + error = %error, + event = %event.id, + "unable to record report" + ); + } + + Ok(()) + }) + } +} + +/// The NIP-56 block (`report-event-strategy.ts:41-95`), best-effort as one unit. +async fn record_report(event: &Event, context: &HandlerContext) -> Result<(), RelayError> { + let Some(nip56) = context + .settings + .nip56 + .as_ref() + .filter(|nip56| nip56.enabled) + else { + return Ok(()); + }; + + let pubkey = event.pubkey.to_string(); + let is_trusted_moderator = nip56 + .trusted_moderators + .iter() + .any(|moderator| moderator.0 == pubkey); + + // A moderator's weight does not depend on distance, and the lookup can trigger a full + // graph rebuild, so it is skipped for them. + let distance = if is_trusted_moderator { + None + } else { + context.wot.get_distance(&event.pubkey).await? + }; + + let weight = score_reporter(distance, is_trusted_moderator).weight(); + + let reports: Vec = extract_report_targets(&event.tags) + .into_iter() + .filter(|target| target.reported_pubkey.is_some() || target.reported_event_id.is_some()) + .map(|target| NewReport { + event_id: event.id, + reporter_pubkey: event.pubkey, + reported_pubkey: target.reported_pubkey, + reported_event_id: target.reported_event_id, + report_type: target.report_type.into(), + weight, + actionable: is_trusted_moderator, }) + .collect(); + + if reports.is_empty() { + return Ok(()); } + + context.stores.reports.create_many(&reports).await?; + + // Keeps the live fan-out in step with what a fresh REQ would now exclude. + if is_trusted_moderator && nip56.hide_actionable_reports { + for report in &reports { + context.hidden.mark_actionable_target(&ActionableTarget { + reported_pubkey: report.reported_pubkey, + reported_event_id: report.reported_event_id, + }); + } + } + + Ok(()) } diff --git a/crates/nostream-relay/src/strategies/timestamp.rs b/crates/nostream-relay/src/strategies/timestamp.rs index eb98f9da9..71df66c3a 100644 --- a/crates/nostream-relay/src/strategies/timestamp.rs +++ b/crates/nostream-relay/src/strategies/timestamp.rs @@ -1,29 +1,116 @@ -//! The `timestamp` event strategy. Mirrors `src/handlers/event-strategies/timestamp-event-strategy.ts`. +//! The `timestamp` event strategy. Mirrors +//! `src/handlers/event-strategies/timestamp-event-strategy.ts`. +//! +//! NIP-03 kind 1040. Unlike most kinds, a structurally invalid attestation is refused before it +//! is stored: a timestamp that does not commit to the event it names is worse than useless to a +//! client. Seven structural rules run first — one `e` tag naming a 32-byte lowercase hex event +//! id, and at most one `k` tag holding a non-negative integer kind — and then the OpenTimestamps +//! proof itself, whose sha256 digest must equal the `e`-tagged id and which must carry at least +//! one Bitcoin attestation. After that it is the common create-or-dedupe. use std::future::Future; use std::pin::Pin; +use std::sync::Arc; -use nostream_core::Event; +use nostream_core::nip03::validate_ots_proof; +use nostream_core::{Event, EventId}; use crate::error::RelayError; use crate::handlers::HandlerContext; -use crate::strategies::EventStrategy; +use crate::strategies::{EventStrategy, event_record}; /// The `timestamp` arm. #[derive(Debug, Clone, Copy, Default)] pub struct TimestampStrategy; 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, + event: &'a Event, + context: &'a HandlerContext, ) -> Pin> + Send + 'a>> { - Box::pin(async { - Err(RelayError::NotImplemented { - module: "relay::strategies::timestamp", - }) + Box::pin(async move { + if let Some(reason) = validate(event) { + context.send_ok(event.id, false, &format!("invalid: {reason}"), None); + + return Ok(()); + } + + let record = event_record(event, context); + let count = context.stores.events.create(&record).await?; + + context.send_ok( + event.id, + true, + if count > 0 { "" } else { "duplicate:" }, + None, + ); + + if count > 0 { + let _ = context.broadcast.send(Arc::new(event.clone())); + } + + Ok(()) }) } } + +/// `validate` (`timestamp-event-strategy.ts:51-91`): the structural rules, then the proof. +fn validate(event: &Event) -> Option { + let attested: Vec<&Vec> = event + .tags + .iter() + .filter(|tag| tag.len() >= 2 && tag[0] == "e") + .collect(); + + if attested.is_empty() { + return Some( + "opentimestamps event (kind 1040) must have an e tag referencing the attested event" + .to_owned(), + ); + } + + if attested.len() > 1 { + // A proof commits to one digest, so more than one `e` tag would let a publisher + // mis-attribute a valid proof to unrelated events. + return Some( + "opentimestamps event (kind 1040) must reference exactly one event".to_owned(), + ); + } + + let target = &attested[0][1]; + + // `EventId`'s parser is the rule the capture's `/^[0-9a-f]{64}$/` states, so the refusal + // and the id the proof is checked against are the same decision. + let Ok(target_event_id) = target.parse::() else { + return Some( + "opentimestamps e tag must contain a 32-byte lowercase hex event id".to_owned(), + ); + }; + + let kinds: Vec<&Vec> = event + .tags + .iter() + .filter(|tag| tag.len() >= 2 && tag[0] == "k") + .collect(); + + if kinds.len() > 1 { + return Some("opentimestamps event (kind 1040) must have at most one k tag".to_owned()); + } + + if let Some(kind) = kinds.first() + && !is_non_negative_integer(&kind[1]) + { + return Some("opentimestamps k tag must be a non-negative integer kind".to_owned()); + } + + validate_ots_proof(&event.content, &target_event_id).err() +} + +/// The capture's `/^\d+$/` plus its `Number.isInteger` and `>= 0` tests: a run of ASCII digits +/// that fits a `u64` (the capture's `Number` accepts more, which no case exercises). +fn is_non_negative_integer(value: &str) -> bool { + !value.is_empty() + && value.bytes().all(|byte| byte.is_ascii_digit()) + && value.parse::().is_ok() +} diff --git a/crates/nostream-relay/src/strategies/vanish.rs b/crates/nostream-relay/src/strategies/vanish.rs index cbd97d7d2..649c489a2 100644 --- a/crates/nostream-relay/src/strategies/vanish.rs +++ b/crates/nostream-relay/src/strategies/vanish.rs @@ -1,29 +1,55 @@ //! The `vanish` event strategy. Mirrors `src/handlers/event-strategies/vanish-event-strategy.ts`. +//! +//! A NIP-62 request to vanish: soft-delete every live event of the requester's except other +//! kind-62 requests, store the request itself, then mark the pubkey vanished. +//! +//! Two things are load-bearing here. There is **no broadcast** — the request is answered to its +//! author alone — and there is **no catch**: a storage failure escapes to the handler, which +//! answers `error: unable to process event` rather than a policy refusal. use std::future::Future; use std::pin::Pin; -use nostream_core::Event; +use nostream_core::{Event, Kind}; use crate::error::RelayError; use crate::handlers::HandlerContext; -use crate::strategies::EventStrategy; +use crate::strategies::{EventStrategy, event_record}; /// The `vanish` arm. #[derive(Debug, Clone, Copy, Default)] pub struct VanishStrategy; 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, + event: &'a Event, + context: &'a HandlerContext, ) -> Pin> + Send + 'a>> { - Box::pin(async { - Err(RelayError::NotImplemented { - module: "relay::strategies::vanish", - }) + Box::pin(async move { + context + .stores + .events + .delete_by_pubkey_except_kinds(&event.pubkey, &[Kind::REQUEST_TO_VANISH]) + .await?; + + let record = event_record(event, context); + let count = context.stores.events.create(&record).await?; + + context + .stores + .users + .set_vanished(&event.pubkey, true) + .await?; + + context.send_ok( + event.id, + true, + if count > 0 { "" } else { "duplicate:" }, + None, + ); + + Ok(()) }) } } diff --git a/crates/nostream-relay/src/testing.rs b/crates/nostream-relay/src/testing.rs index d03d90350..6a17ca304 100644 --- a/crates/nostream-relay/src/testing.rs +++ b/crates/nostream-relay/src/testing.rs @@ -24,9 +24,9 @@ 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, + EventRepository, EventRetentionOptions, FieldUpdate, InviteCode, InviteCodeRepository, + NewReport, Nip05Verification, Nip05VerificationRepository, QueryStream, ReconciliationItem, + Report, ReportRepository, StorageFuture, User, UserPatch, UserRepository, }; use crate::connection::{Connection, Subscription}; @@ -35,6 +35,7 @@ use crate::handlers::{HandlerContext, Stores}; use crate::hidden_content::HiddenContentCache; use crate::limits::RateLimits; use crate::relay_key::RelayKeySource; +use crate::wot::{WotFuture, WotGraph}; /// A settings document with the fields the relay reads, and nothing else. /// @@ -132,12 +133,26 @@ pub fn nip56_settings() -> Nip56Settings { } } +/// What [`InMemoryEventRepository::fail_upsert`] makes `upsert` answer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpsertFailure { + /// A store that cannot be reached. + Unavailable, + /// The event id already exists (`StorageError::DuplicateEvent`). + DuplicateEvent, +} + /// An event repository holding its rows in memory. #[derive(Debug, Default)] pub struct InMemoryEventRepository { records: Mutex>, fail_count: AtomicBool, fail_queries: AtomicBool, + fail_delete: AtomicBool, + upsert_failure: Mutex>, + upserts: Mutex>>, + deletes: Mutex)>>, + delete_except_kinds_calls: Mutex)>>, /// 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>>, @@ -220,6 +235,73 @@ impl InMemoryEventRepository { .map(|record| record.event.clone()) .collect() } + + /// The events a read still returns, in insertion order: the rows no soft delete touched. + pub fn live_events(&self) -> Vec { + self.records + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .iter() + .filter(|record| record.deleted_at.is_none()) + .map(|record| record.event.clone()) + .collect() + } + + /// The deduplication key each `upsert` was asked to write, in call order. + pub fn upserts(&self) -> Vec> { + self.upserts + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + + /// The `(pubkey, ids)` each `delete_by_pubkey_and_ids` was called with. + pub fn deletes(&self) -> Vec<(Pubkey, Vec)> { + self.deletes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + + /// The `(pubkey, excluded kinds)` each `delete_by_pubkey_except_kinds` was called with. + pub fn delete_except_kinds_calls(&self) -> Vec<(Pubkey, Vec)> { + self.delete_except_kinds_calls + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + + /// Makes every `upsert` answer `failure` before it looks at a row. + pub fn fail_upsert(&self, failure: UpsertFailure) { + *self + .upsert_failure + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(failure); + } + + /// Makes both soft deletes fail. + pub fn fail_delete(&self) { + self.fail_delete.store(true, Ordering::SeqCst); + } + + /// Soft-deletes every live row matching `predicate`, returning how many it touched. + fn soft_delete(&self, predicate: impl Fn(&EventRecord) -> bool) -> u64 { + let now = chrono::Utc::now(); + let mut records = self + .records + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut deleted = 0; + + for record in records.iter_mut() { + if record.deleted_at.is_none() && predicate(record) { + record.deleted_at = Some(now); + deleted += 1; + } + } + + deleted + } } impl EventRepository for InMemoryEventRepository { @@ -253,12 +335,60 @@ impl EventRepository for InMemoryEventRepository { Box::pin(async move { Ok(records.len() as u64) }) } + /// The capture's `INSERT … ON CONFLICT (event_pubkey, event_kind, event_deduplication)`: + /// the conflict target applies to replaceable kinds only, the incoming row wins when it + /// supersedes the stored one, and the answer is whether a row was written. fn upsert<'a>(&'a self, record: &'a EventRecord) -> StorageFuture<'a, u64> { Box::pin(async move { + let deduplication = record.deduplication(); + + self.upserts + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(deduplication.clone()); + + if let Some(failure) = *self + .upsert_failure + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + { + return Err(match failure { + UpsertFailure::Unavailable => StorageError::InvalidArgument { + message: "connection lost", + }, + UpsertFailure::DuplicateEvent => StorageError::DuplicateEvent, + }); + } + let mut records = self .records .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); + + if record.event.kind.is_replaceable() + || record.event.kind.is_parameterized_replaceable() + { + let stored = records.iter_mut().find(|stored| { + stored.event.pubkey == record.event.pubkey + && stored.event.kind == record.event.kind + && stored.deduplication() == deduplication + }); + + if let Some(stored) = stored { + if !nostream_core::replaceable::supersedes( + &record.event, + stored.event.created_at, + &stored.event.id, + ) { + return Ok(0); + } + + *stored = record.clone(); + + return Ok(1); + } + } + records.push(record.clone()); Ok(1) @@ -372,27 +502,53 @@ impl EventRepository for InMemoryEventRepository { ) } + /// `UPDATE … SET deleted_at = now()` for the given ids of one author, skipping kind 62. fn delete_by_pubkey_and_ids<'a>( &'a self, - _pubkey: &'a Pubkey, - _ids: &'a [EventId], + 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", - }) + self.deletes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push((*pubkey, ids.to_vec())); + + if self.fail_delete.load(Ordering::SeqCst) { + return Err(StorageError::InvalidArgument { + message: "in-memory double: the delete failed", + }); + } + + Ok(self.soft_delete(|record| { + record.event.pubkey == *pubkey + && ids.contains(&record.event.id) + && record.event.kind != Kind::REQUEST_TO_VANISH + })) }) } + /// The same soft delete over every live row of one author but the excluded kinds. fn delete_by_pubkey_except_kinds<'a>( &'a self, - _pubkey: &'a Pubkey, - _excluded_kinds: &'a [Kind], + 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", - }) + self.delete_except_kinds_calls + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push((*pubkey, excluded_kinds.to_vec())); + + if self.fail_delete.load(Ordering::SeqCst) { + return Err(StorageError::InvalidArgument { + message: "in-memory double: the delete failed", + }); + } + + Ok(self.soft_delete(|record| { + record.event.pubkey == *pubkey && !excluded_kinds.contains(&record.event.kind) + })) }) } @@ -412,6 +568,7 @@ impl EventRepository for InMemoryEventRepository { #[derive(Debug, Default)] pub struct InMemoryUserRepository { users: Mutex>, + vanish_calls: Mutex>, fail_reads: AtomicBool, } @@ -433,6 +590,14 @@ impl InMemoryUserRepository { .unwrap_or_else(|poisoned| poisoned.into_inner()) .insert(user.pubkey, user); } + + /// The `(pubkey, vanished)` each `set_vanished` was called with. + pub fn vanish_calls(&self) -> Vec<(Pubkey, bool)> { + self.vanish_calls + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } } impl UserRepository for InMemoryUserRepository { @@ -499,37 +664,59 @@ impl UserRepository for InMemoryUserRepository { }) } + /// The capture's insert-on-conflict-merge (`src/repositories/user-repository.ts:75-92`): + /// an absent row is created, so the flag lands for a pubkey this relay has never seen. fn set_vanished<'a>(&'a self, pubkey: &'a Pubkey, vanished: bool) -> StorageFuture<'a, u64> { Box::pin(async move { + self.vanish_calls + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push((*pubkey, vanished)); + + let now = chrono::Utc::now(); let mut users = self .users .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); + let user = users.entry(*pubkey).or_insert_with(|| User { + pubkey: *pubkey, + is_admitted: false, + is_vanished: false, + balance: 0, + created_at: now, + updated_at: now, + }); - match users.get_mut(pubkey) { - Some(user) => { - user.is_vanished = vanished; - Ok(1) - } - None => Ok(0), - } + user.is_vanished = vanished; + user.updated_at = now; + + Ok(1) }) } + /// The capture's `admit_user` function: an upsert, so a pubkey with no row gets one. fn admit_user<'a>( &'a self, pubkey: &'a Pubkey, _admitted_at: chrono::DateTime, ) -> StorageFuture<'a, ()> { Box::pin(async move { - if let Some(user) = self + let now = chrono::Utc::now(); + let mut users = self .users .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .get_mut(pubkey) - { - user.is_admitted = true; - } + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let user = users.entry(*pubkey).or_insert_with(|| User { + pubkey: *pubkey, + is_admitted: false, + is_vanished: false, + balance: 0, + created_at: now, + updated_at: now, + }); + + user.is_admitted = true; + user.updated_at = now; Ok(()) }) @@ -634,10 +821,38 @@ impl Nip05VerificationRepository for InMemoryNip05Repository { } } -/// An invite-code repository that records the codes it mints. +/// One invite code as the double holds it: the mint options, plus the claim state a claim +/// mutates. +#[derive(Debug, Clone)] +struct InviteCodeRow { + options: CreateInviteCodeOptions, + claimed_by: Option, + /// `None` is no limit at all, which is the capture's `Clear`. + remaining_uses: Option, +} + +impl InviteCodeRow { + fn code(&self, code: &str) -> InviteCode { + let now = chrono::Utc::now(); + + InviteCode { + code: code.to_owned(), + created_by: self.options.created_by, + claimed_by: self.claimed_by, + expires_at: self.options.expires_at, + remaining_uses: self.remaining_uses, + created_at: now, + updated_at: now, + } + } +} + +/// An invite-code repository that records the codes it mints and claims. #[derive(Debug, Default)] pub struct InMemoryInviteCodeRepository { - codes: Mutex>, + codes: Mutex>, + claims: Mutex>, + fail_claims: AtomicBool, /// When set, `create` holds until a permit is released, which is how a test keeps the /// invite mint's one `await` open long enough for a `CLOSE` to land in it. create_gate: Mutex>>, @@ -677,9 +892,22 @@ impl InMemoryInviteCodeRepository { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .iter() - .map(|(code, options)| (code.clone(), options.clone())) + .map(|(code, row)| (code.clone(), row.options.clone())) .collect() } + + /// The `(code, pubkey)` each `claim_code` was called with, in call order. + pub fn claims(&self) -> Vec<(String, Pubkey)> { + self.claims + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + + /// Makes every `claim_code` fail as an unavailable store would. + pub fn fail_claims(&self) { + self.fail_claims.store(true, Ordering::SeqCst); + } } impl InviteCodeRepository for InMemoryInviteCodeRepository { @@ -701,11 +929,25 @@ impl InviteCodeRepository for InMemoryInviteCodeRepository { let _ = gate.acquire().await; } + let row = InviteCodeRow { + options: options.clone(), + claimed_by: None, + remaining_uses: match options.remaining_uses { + // The capture's `options.remainingUses === undefined ? 1 : remainingUses` + // (`src/repositories/invite-code-repository.ts:42`): absent is one use, + // `Clear` (the capture's `null`) is unlimited. + FieldUpdate::Absent => Some(1), + FieldUpdate::Clear => None, + FieldUpdate::Set(uses) => Some(uses), + }, + }; + let invite = row.code(code); + if self .codes .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) - .insert(code.to_owned(), options.clone()) + .insert(code.to_owned(), row) .is_some() { return Err(StorageError::InvalidArgument { @@ -713,20 +955,7 @@ impl InviteCodeRepository for InMemoryInviteCodeRepository { }); } - 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, - }) + Ok(invite) }) } @@ -737,20 +966,57 @@ impl InviteCodeRepository for InMemoryInviteCodeRepository { .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(), - })) + .map(|row| row.code(code))) }) } - fn claim_code<'a>(&'a self, _code: &'a str, _pubkey: &'a Pubkey) -> StorageFuture<'a, bool> { - Box::pin(async move { Ok(true) }) + /// One use of the code, refused only when it is absent, expired or out of uses: the + /// capture's single conditional update (`claimCode`, + /// `src/repositories/invite-code-repository.ts:74-95`, and `invite_claim_code`, + /// `crates/nostream-storage/src/postgres/metadata.rs:212-236`). A code shared between + /// pubkeys keeps admitting them until its uses run out, and `claimed_by` records the first + /// claimant alone (`COALESCE(claimed_by, …)`). + fn claim_code<'a>(&'a self, code: &'a str, pubkey: &'a Pubkey) -> StorageFuture<'a, bool> { + Box::pin(async move { + self.claims + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push((code.to_owned(), *pubkey)); + + if self.fail_claims.load(Ordering::SeqCst) { + return Err(StorageError::InvalidArgument { + message: "in-memory double: the claim failed", + }); + } + + let mut codes = self + .codes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let Some(row) = codes.get_mut(code) else { + return Ok(false); + }; + + if row + .options + .expires_at + .is_some_and(|expires_at| expires_at <= chrono::Utc::now()) + { + return Ok(false); + } + + if let Some(remaining) = row.remaining_uses { + if remaining <= 0 { + return Ok(false); + } + + row.remaining_uses = Some(remaining - 1); + } + + row.claimed_by = row.claimed_by.or(Some(*pubkey)); + + Ok(true) + }) } fn find_active_codes<'a>(&'a self, _limit: u64) -> StorageFuture<'a, Vec> { @@ -762,14 +1028,30 @@ impl InviteCodeRepository for InMemoryInviteCodeRepository { } } -/// A DVM job repository that records nothing the relay reads. +/// A DVM job repository that records every job it is asked to create. #[derive(Debug, Default)] -pub struct InMemoryDvmJobRepository; +pub struct InMemoryDvmJobRepository { + jobs: Mutex>, + fail_writes: AtomicBool, +} impl InMemoryDvmJobRepository { /// An empty store. pub fn new() -> Arc { - Arc::new(Self) + Arc::new(Self::default()) + } + + /// The jobs recorded, in creation order. + pub fn jobs(&self) -> Vec { + self.jobs + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + + /// Makes every write fail, as an unavailable database would. + pub fn fail_writes(&self) { + self.fail_writes.store(true, Ordering::SeqCst); } } @@ -781,9 +1063,14 @@ impl nostream_storage::repositories::DvmJobRepository for InMemoryDvmJobReposito kind: Kind, ) -> StorageFuture<'a, DvmJob> { Box::pin(async move { - let now = chrono::Utc::now(); + if self.fail_writes.load(Ordering::SeqCst) { + return Err(StorageError::InvalidArgument { + message: "in-memory double: the dvm job write failed", + }); + } - Ok(DvmJob { + let now = chrono::Utc::now(); + let job = DvmJob { id: *id, requester_pubkey: *requester, kind, @@ -795,12 +1082,27 @@ impl nostream_storage::repositories::DvmJobRepository for InMemoryDvmJobReposito completed_at: None, created_at: now, updated_at: now, - }) + }; + + self.jobs + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(job.clone()); + + Ok(job) }) } - fn find_by_id<'a>(&'a self, _id: &'a EventId) -> StorageFuture<'a, Option> { - Box::pin(async move { Ok(None) }) + fn find_by_id<'a>(&'a self, id: &'a EventId) -> StorageFuture<'a, Option> { + Box::pin(async move { + Ok(self + .jobs + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .iter() + .find(|job| job.id == *id) + .cloned()) + }) } fn assign_worker<'a>( @@ -827,12 +1129,15 @@ impl nostream_storage::repositories::DvmJobRepository for InMemoryDvmJobReposito } } -/// A report repository with one settable actionable-target snapshot. +/// A report repository with one settable actionable-target snapshot, which also records the +/// rows it is asked to write. #[derive(Debug, Default)] pub struct InMemoryReportRepository { targets: Mutex>, queries: AtomicU64, fail_queries: AtomicBool, + writes: Mutex>>, + fail_writes: AtomicBool, } impl InMemoryReportRepository { @@ -858,25 +1163,69 @@ impl InMemoryReportRepository { pub fn fail_queries(&self) { self.fail_queries.store(true, Ordering::SeqCst); } + + /// The report rows each write was asked for, one entry per call. + pub fn report_writes(&self) -> Vec> { + self.writes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + + /// Every report row written, in call order. + pub fn reports(&self) -> Vec { + self.report_writes().into_iter().flatten().collect() + } + + /// Makes every report write fail, as an unavailable database would. + pub fn fail_writes(&self) { + self.fail_writes.store(true, Ordering::SeqCst); + } } impl ReportRepository for InMemoryReportRepository { - fn create<'a>( - &'a self, - _report: &'a nostream_storage::repositories::NewReport, - ) -> StorageFuture<'a, Report> { + fn create<'a>(&'a self, report: &'a NewReport) -> StorageFuture<'a, Report> { Box::pin(async move { - Err(StorageError::InvalidArgument { - message: "in-memory double: not used by these tests", - }) + if self.fail_writes.load(Ordering::SeqCst) { + return Err(StorageError::InvalidArgument { + message: "in-memory double: the report write failed", + }); + } + + let mut writes = self + .writes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + writes.push(vec![report.clone()]); + + Ok(stored_report(written_rows(&writes), report)) }) } - fn create_many<'a>( - &'a self, - _reports: &'a [nostream_storage::repositories::NewReport], - ) -> StorageFuture<'a, Vec> { - Box::pin(async move { Ok(Vec::new()) }) + /// One insert of the whole batch, in input order — one row per input. + fn create_many<'a>(&'a self, batch: &'a [NewReport]) -> StorageFuture<'a, Vec> { + Box::pin(async move { + if self.fail_writes.load(Ordering::SeqCst) { + return Err(StorageError::InvalidArgument { + message: "in-memory double: the report write failed", + }); + } + + let mut writes = self + .writes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + writes.push(batch.to_vec()); + + let first = written_rows(&writes) - batch.len() as i32; + let rows = batch + .iter() + .enumerate() + .map(|(index, report)| stored_report(first + index as i32 + 1, report)) + .collect(); + + Ok(rows) + }) } fn find_by_event_id<'a>(&'a self, _event_id: &'a EventId) -> StorageFuture<'a, Vec> { @@ -908,6 +1257,121 @@ impl ReportRepository for InMemoryReportRepository { } } +/// How many report rows the recorded writes hold. +fn written_rows(writes: &[Vec]) -> i32 { + writes.iter().map(|write| write.len() as i32).sum() +} + +/// The row a report write answers with: the input's fields, a synthesised id. +fn stored_report(id: i32, report: &NewReport) -> Report { + Report { + id, + event_id: report.event_id, + reporter_pubkey: report.reporter_pubkey, + reported_pubkey: report.reported_pubkey, + reported_event_id: report.reported_event_id, + report_type: report.report_type, + weight: report.weight, + actionable: report.actionable, + created_at: chrono::Utc::now(), + } +} + +/// A trust graph that records every call, so a test can assert which strategy reached it and +/// with what. +#[derive(Debug, Default)] +pub struct RecordingWotGraph { + follow_updates: Mutex)>>, + distance_queries: Mutex>, + distance: Mutex>, + fail_updates: AtomicBool, + fail_distances: AtomicBool, +} + +impl RecordingWotGraph { + /// An empty graph: every pubkey is outside it. + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + /// The hop distance every query answers; `None` means "outside the graph". + pub fn set_distance(&self, distance: Option) { + *self + .distance + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = distance; + } + + /// The `(pubkey, follows)` each `update_follow_list` was called with. + pub fn follow_updates(&self) -> Vec<(Pubkey, Vec)> { + self.follow_updates + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + + /// The pubkey each `get_distance` was called with. + pub fn distance_queries(&self) -> Vec { + self.distance_queries + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + + /// Makes every `update_follow_list` fail. + pub fn fail_updates(&self) { + self.fail_updates.store(true, Ordering::SeqCst); + } + + /// Makes every `get_distance` fail. + pub fn fail_distances(&self) { + self.fail_distances.store(true, Ordering::SeqCst); + } +} + +impl WotGraph for RecordingWotGraph { + fn update_follow_list<'a>( + &'a self, + pubkey: &'a Pubkey, + follows: &'a [String], + ) -> WotFuture<'a, ()> { + Box::pin(async move { + self.follow_updates + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push((*pubkey, follows.to_vec())); + + if self.fail_updates.load(Ordering::SeqCst) { + return Err(RelayError::Storage(StorageError::InvalidArgument { + message: "in-memory double: the wot graph update failed", + })); + } + + Ok(()) + }) + } + + fn get_distance<'a>(&'a self, pubkey: &'a Pubkey) -> WotFuture<'a, Option> { + Box::pin(async move { + self.distance_queries + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(*pubkey); + + if self.fail_distances.load(Ordering::SeqCst) { + return Err(RelayError::Storage(StorageError::InvalidArgument { + message: "in-memory double: the wot graph query failed", + })); + } + + Ok(*self + .distance + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner())) + }) + } +} + /// A limiter whose store always fails, for the fail-closed paths. #[derive(Debug, Default, Clone, Copy)] pub struct FailingLimiter; @@ -936,13 +1400,14 @@ pub fn stores( nip05: Arc, invites: Arc, reports: Arc, + dvm: Arc, ) -> Arc { Arc::new(Stores { events, users, nip05, invites, - dvm_jobs: InMemoryDvmJobRepository::new(), + dvm_jobs: dvm, reports, }) } @@ -956,6 +1421,8 @@ pub struct Fixture { pub nip05: Arc, pub invites: Arc, pub reports: Arc, + pub dvm: Arc, + pub wot: Arc, pub hidden: Arc, pub hub: tokio::sync::broadcast::Sender>, } @@ -968,6 +1435,8 @@ impl Fixture { let nip05 = InMemoryNip05Repository::new(); let invites = InMemoryInviteCodeRepository::new(); let reports = InMemoryReportRepository::new(); + let dvm = InMemoryDvmJobRepository::new(); + let wot = RecordingWotGraph::new(); let (hub, _receiver) = tokio::sync::broadcast::channel(64); Self { @@ -978,17 +1447,30 @@ impl Fixture { Arc::clone(&nip05), Arc::clone(&invites), Arc::clone(&reports), + Arc::clone(&dvm), ), events, users, nip05, invites, reports, + dvm, + wot, hidden: HiddenContentCache::new(), hub, } } + /// This fixture's trust-graph recorder, as a [`HandlerContext`] holds it. + /// + /// The coercion from the recorder to the trait object lives here, so every caller that + /// builds a context or a [`Relay`](crate::relay::Relay) passes the same handle. + pub fn wot_graph(&self) -> Arc { + let recorder: Arc = Arc::clone(&self.wot); + + recorder + } + /// 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 { @@ -1026,6 +1508,8 @@ impl Fixture { sink: crate::sink::FrameSink, limits: Arc, ) -> HandlerContext { + let wot = self.wot_graph(); + HandlerContext { settings: Arc::clone(&self.settings), clock: Arc::clone(&clock), @@ -1033,6 +1517,7 @@ impl Fixture { sink, connection, stores: Arc::clone(&self.stores), + wot, limits, hidden: Arc::clone(&self.hidden), keys: Arc::new(keys), @@ -1086,3 +1571,88 @@ pub fn hub_receiver( ) -> tokio::sync::broadcast::Receiver> { hub.subscribe() } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use super::*; + + fn claimant(byte: u8) -> Pubkey { + Pubkey::from_byte_array([byte; 32]) + } + + fn options(remaining_uses: FieldUpdate) -> CreateInviteCodeOptions { + CreateInviteCodeOptions { + expires_at: None, + remaining_uses, + created_by: None, + } + } + + #[tokio::test] + async fn a_shared_invite_code_admits_a_second_pubkey_until_its_uses_run_out() { + // The capture's `claimCode` conditions on `code`, the remaining uses and the expiry + // only (`src/repositories/invite-code-repository.ts:74-95`), so a code with uses left + // admits every claimant and only the count runs out. The double used to refuse the + // second pubkey — behaviour the relay does not have. + let invites = InMemoryInviteCodeRepository::new(); + invites + .create("shared", &options(FieldUpdate::Set(2))) + .await + .unwrap(); + + assert!(invites.claim_code("shared", &claimant(1)).await.unwrap()); + assert!(invites.claim_code("shared", &claimant(2)).await.unwrap()); + assert!(!invites.claim_code("shared", &claimant(3)).await.unwrap()); + + let row = invites.find_by_code("shared").await.unwrap().unwrap(); + + assert_eq!(row.claimed_by, Some(claimant(1))); + assert_eq!(row.remaining_uses, Some(0)); + } + + #[tokio::test] + async fn an_unlimited_invite_code_never_runs_out_and_keeps_the_first_claimant() { + // `remaining_uses IS NULL` is the capture's `null` ("unlimited uses"), and the SQL's + // `remaining_uses - 1` leaves it null. + let invites = InMemoryInviteCodeRepository::new(); + invites + .create("unlimited", &options(FieldUpdate::Clear)) + .await + .unwrap(); + + for byte in 1..=3 { + assert!( + invites + .claim_code("unlimited", &claimant(byte)) + .await + .unwrap() + ); + } + + let row = invites.find_by_code("unlimited").await.unwrap().unwrap(); + + assert_eq!(row.claimed_by, Some(claimant(1))); + assert_eq!(row.remaining_uses, None); + } + + #[tokio::test] + async fn a_code_created_without_a_use_count_is_single_use() { + // The capture's `remainingUses === undefined ? 1 : remainingUses` (`:42`): an absent + // count is one use, not unlimited. + let invites = InMemoryInviteCodeRepository::new(); + invites + .create("default", &options(FieldUpdate::Absent)) + .await + .unwrap(); + + assert!(invites.claim_code("default", &claimant(1)).await.unwrap()); + assert!(!invites.claim_code("default", &claimant(2)).await.unwrap()); + + let row = invites.find_by_code("default").await.unwrap().unwrap(); + + assert_eq!(row.claimed_by, Some(claimant(1))); + assert_eq!(row.remaining_uses, Some(0)); + } +} diff --git a/crates/nostream-relay/src/unavailable.rs b/crates/nostream-relay/src/unavailable.rs index 2bbaa1013..0c23895e6 100644 --- a/crates/nostream-relay/src/unavailable.rs +++ b/crates/nostream-relay/src/unavailable.rs @@ -18,8 +18,9 @@ use nostream_storage::repositories::{ }; use crate::handlers::Stores; +use crate::wot::{WotFuture, WotGraph}; -/// The six seams, every call reporting `NotImplemented`. +/// The six stores, every call reporting `NotImplemented`. pub fn stores() -> Stores { Stores { events: std::sync::Arc::new(UnavailableEventRepository), @@ -37,6 +38,34 @@ fn unavailable() -> StorageError { } } +/// The trust graph, for a daemon with no `nostream-services` wiring: the composition root hands +/// it to `Relay::new` where it picks the clock and the key source. "Unavailable" is the +/// capture's *disabled* graph, not a failure: a follow-list update touches nothing and no +/// distance is known, so a report is still stored — unweighted, which is what the capture +/// stores when WoT is off. A genuine failure (a rebuild that could not complete) belongs to the +/// wrapped service, and both callers log it and carry on. +#[derive(Debug, Default, Clone, Copy)] +pub struct UnavailableWotGraph; + +impl WotGraph for UnavailableWotGraph { + fn update_follow_list<'a>( + &'a self, + _pubkey: &'a Pubkey, + _follows: &'a [String], + ) -> WotFuture<'a, ()> { + // `updateFollowList` returns without touching anything when WoT is disabled + // (`src/services/wot-graph-service.ts:74-76`). + Box::pin(async { Ok(()) }) + } + + fn get_distance<'a>(&'a self, _pubkey: &'a Pubkey) -> WotFuture<'a, Option> { + // `getDistance` answers `undefined` when WoT is disabled or has no seed pubkey + // (`src/services/wot-graph-service.ts:55-57`), which the report arm stores as an + // unweighted row rather than aborting the write. + Box::pin(async { Ok(None) }) + } +} + /// The event store. #[derive(Debug, Default, Clone, Copy)] pub struct UnavailableEventRepository; diff --git a/crates/nostream-relay/src/wot.rs b/crates/nostream-relay/src/wot.rs new file mode 100644 index 000000000..396056c98 --- /dev/null +++ b/crates/nostream-relay/src/wot.rs @@ -0,0 +1,36 @@ +//! The web-of-trust seam. +//! +//! Two strategies call a trust graph at the capture: the contact-list arm replaces a pubkey's +//! follows (`updateFollowList`, `contact-list-event-strategy.ts:36`) and the report arm asks +//! how far a reporter sits from the relay's seed (`getDistance`, +//! `report-event-strategy.ts:53`). +//! +//! The graph itself is a service — `nostream-services`' `wot_graph_service`, issue #866 — and +//! this crate takes no dependency on that one: it takes the trait, the way it takes +//! [`crate::limits::Limiter`] for the rate limiter — and the composition root supplies the +//! instance, which [`crate::handlers::HandlerContext`] carries beside `limits`. Both callers +//! treat the seam as best-effort, so a daemon whose graph is not wired still serves. + +use std::future::Future; +use std::pin::Pin; + +use nostream_core::Pubkey; + +use crate::error::RelayError; + +/// One web-of-trust call's future, in [`crate::strategies::EventStrategy::execute`]'s style. +pub type WotFuture<'a, T> = Pin> + Send + 'a>>; + +/// `IWotGraphService`: the graph's two operations. +pub trait WotGraph: Send + Sync { + /// Replaces the pubkeys `pubkey` follows (`updateFollowList`). + fn update_follow_list<'a>( + &'a self, + pubkey: &'a Pubkey, + follows: &'a [String], + ) -> WotFuture<'a, ()>; + + /// The hop distance from the relay's seed to `pubkey`, `None` when it is outside the + /// graph (`getDistance`). + fn get_distance<'a>(&'a self, pubkey: &'a Pubkey) -> WotFuture<'a, Option>; +} diff --git a/crates/nostream-relay/tests/capture/gen-ts-cases.mjs b/crates/nostream-relay/tests/capture/gen-ts-cases.mjs index 9ebeb6177..a921503a5 100644 --- a/crates/nostream-relay/tests/capture/gen-ts-cases.mjs +++ b/crates/nostream-relay/tests/capture/gen-ts-cases.mjs @@ -12,7 +12,7 @@ // 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 +// The generator runs mocha with its JSON reporter over the 36 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 @@ -23,14 +23,28 @@ import { execFileSync } from 'node:child_process' const CAPTURE = '242a3001d77149e4d70151ec03060c4ef44a1547' -// The spec's Context table: the 22 in-scope files and their measured case counts. +// The spec's Context table: the 36 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/contact-list-event-strategy.spec.ts', 7], ['test/unit/handlers/event-strategies/default-event-strategy.spec.ts', 4], + ['test/unit/handlers/event-strategies/delete-event-strategy.spec.ts', 6], + ['test/unit/handlers/event-strategies/dvm-job-request-event-strategy.spec.ts', 6], + ['test/unit/handlers/event-strategies/ephemeral-event-strategy.spec.ts', 1], + ['test/unit/handlers/event-strategies/gift-wrap-event-strategy.spec.ts', 9], + ['test/unit/handlers/event-strategies/group-event-strategy.spec.ts', 12], + ['test/unit/handlers/event-strategies/invite-request-event-strategy.spec.ts', 2], + ['test/unit/handlers/event-strategies/join-request-event-strategy.spec.ts', 8], + ['test/unit/handlers/event-strategies/leave-request-event-strategy.spec.ts', 8], + ['test/unit/handlers/event-strategies/parameterized-replaceable-event-strategy.spec.ts', 6], + ['test/unit/handlers/event-strategies/replaceable-event-strategy.spec.ts', 4], + ['test/unit/handlers/event-strategies/report-event-strategy.spec.ts', 16], + ['test/unit/handlers/event-strategies/timestamp-event-strategy.spec.ts', 13], + ['test/unit/handlers/event-strategies/vanish-event-strategy.spec.ts', 3], ['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], diff --git a/crates/nostream-relay/tests/common/mod.rs b/crates/nostream-relay/tests/common/mod.rs index 5958d79d0..7d3ec00c8 100644 --- a/crates/nostream-relay/tests/common/mod.rs +++ b/crates/nostream-relay/tests/common/mod.rs @@ -58,6 +58,7 @@ impl Server { let relay = Relay::new( (*fixture.settings).clone(), Arc::clone(&fixture.stores), + fixture.wot_graph(), clock.clone(), keys, random, diff --git a/crates/nostream-relay/tests/fixtures/ts-case-map.json b/crates/nostream-relay/tests/fixtures/ts-case-map.json index 54bbec2f5..60c6e390c 100644 --- a/crates/nostream-relay/tests/fixtures/ts-case-map.json +++ b/crates/nostream-relay/tests/fixtures/ts-case-map.json @@ -1,7 +1,7 @@ { "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, + "cases": 658, "groups": [ { "file": "test/unit/handlers/event-message-handler.spec.ts", @@ -554,6 +554,55 @@ "UnsubscribeMessageHandler handleMessage() emits unsubscribe event with subscription Id" ] }, + { + "file": "test/unit/handlers/event-strategies/contact-list-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::contact_list_upserts_the_event", + "titles": [ + "ContactListEventStrategy execute upserts the event" + ] + }, + { + "file": "test/unit/handlers/event-strategies/contact-list-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::contact_list_updates_the_wot_graph_with_the_p_tagged_pubkeys_when_newly_stored", + "titles": [ + "ContactListEventStrategy execute updates the wot graph with the p-tagged pubkeys when newly stored" + ] + }, + { + "file": "test/unit/handlers/event-strategies/contact-list-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::contact_list_broadcasts_the_event_when_newly_stored", + "titles": [ + "ContactListEventStrategy execute broadcasts the event when newly stored" + ] + }, + { + "file": "test/unit/handlers/event-strategies/contact-list-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::contact_list_does_not_broadcast_or_update_the_wot_graph_when_the_event_is_a_duplicate", + "titles": [ + "ContactListEventStrategy execute does not broadcast or update the wot graph when the event is a duplicate" + ] + }, + { + "file": "test/unit/handlers/event-strategies/contact-list-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::contact_list_does_not_fail_the_event_when_the_wot_graph_update_fails", + "titles": [ + "ContactListEventStrategy execute does not fail the event when the wot graph update fails" + ] + }, + { + "file": "test/unit/handlers/event-strategies/contact-list-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::contact_list_emits_a_rejection_when_the_event_already_exists", + "titles": [ + "ContactListEventStrategy execute emits a rejection when the event already exists" + ] + }, + { + "file": "test/unit/handlers/event-strategies/contact-list-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::contact_list_returns_an_error_on_an_unexpected_upsert_failure", + "titles": [ + "ContactListEventStrategy execute emits an error message on unexpected upsert failure" + ] + }, { "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", @@ -570,6 +619,652 @@ "DefaultEventStrategy execute rejects if unable to create event" ] }, + { + "file": "test/unit/handlers/event-strategies/delete-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::delete_creates_the_event", + "titles": [ + "DeleteEventStrategy execute creates event" + ] + }, + { + "file": "test/unit/handlers/event-strategies/delete-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::delete_soft_deletes_the_events_its_e_tags_name", + "titles": [ + "DeleteEventStrategy execute deletes events if it has e tags" + ] + }, + { + "file": "test/unit/handlers/event-strategies/delete-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::delete_does_not_delete_when_there_are_no_e_tags", + "titles": [ + "DeleteEventStrategy execute does not delete events if there are no e tags" + ] + }, + { + "file": "test/unit/handlers/event-strategies/delete-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::delete_broadcasts_the_event_when_created", + "titles": [ + "DeleteEventStrategy execute broadcast event if created" + ] + }, + { + "file": "test/unit/handlers/event-strategies/delete-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::delete_does_not_broadcast_when_the_event_is_a_duplicate", + "titles": [ + "DeleteEventStrategy execute does not broadcast event if duplicate" + ] + }, + { + "file": "test/unit/handlers/event-strategies/delete-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::delete_returns_an_error_when_the_create_fails", + "titles": [ + "DeleteEventStrategy execute rejects if unable to create event" + ] + }, + { + "file": "test/unit/handlers/event-strategies/dvm-job-request-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::dvm_job_request_creates_the_event", + "titles": [ + "DvmJobRequestEventStrategy execute creates the event" + ] + }, + { + "file": "test/unit/handlers/event-strategies/dvm-job-request-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::dvm_job_request_records_a_job_when_the_event_is_new", + "titles": [ + "DvmJobRequestEventStrategy execute records a dvm job when the event is newly created" + ] + }, + { + "file": "test/unit/handlers/event-strategies/dvm-job-request-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::dvm_job_request_broadcasts_the_event_when_new", + "titles": [ + "DvmJobRequestEventStrategy execute broadcasts the event when newly created" + ] + }, + { + "file": "test/unit/handlers/event-strategies/dvm-job-request-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::dvm_job_request_does_not_broadcast_or_record_a_job_when_the_event_is_a_duplicate", + "titles": [ + "DvmJobRequestEventStrategy execute does not broadcast or record a job when the event is a duplicate" + ] + }, + { + "file": "test/unit/handlers/event-strategies/dvm-job-request-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::dvm_job_request_does_not_reject_the_event_when_job_recording_fails", + "titles": [ + "DvmJobRequestEventStrategy execute does not reject the event when job recording fails" + ] + }, + { + "file": "test/unit/handlers/event-strategies/dvm-job-request-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::dvm_job_request_returns_an_error_when_the_create_fails", + "titles": [ + "DvmJobRequestEventStrategy execute rejects if unable to create the event" + ] + }, + { + "file": "test/unit/handlers/event-strategies/ephemeral-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::ephemeral_sends_ok_and_broadcasts_without_storing", + "titles": [ + "EphemeralEventStrategy execute broadcasts event" + ] + }, + { + "file": "test/unit/handlers/event-strategies/gift-wrap-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::gift_wrap_stores_a_valid_wrap", + "titles": [ + "GiftWrapEventStrategy execute valid gift wrap creates the event in the repository" + ] + }, + { + "file": "test/unit/handlers/event-strategies/gift-wrap-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::gift_wrap_sends_ok_and_broadcasts_when_new", + "titles": [ + "GiftWrapEventStrategy execute valid gift wrap sends OK and broadcasts when the event is new" + ] + }, + { + "file": "test/unit/handlers/event-strategies/gift-wrap-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::gift_wrap_answers_duplicate_without_broadcasting", + "titles": [ + "GiftWrapEventStrategy execute valid gift wrap sends OK with duplicate marker and does not broadcast when event already exists" + ] + }, + { + "file": "test/unit/handlers/event-strategies/gift-wrap-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::gift_wrap_refuses_a_wrap_without_a_p_tag", + "titles": [ + "GiftWrapEventStrategy execute invalid gift wrap — p tag rejects when the p tag is missing" + ] + }, + { + "file": "test/unit/handlers/event-strategies/gift-wrap-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::gift_wrap_refuses_multiple_p_tags", + "titles": [ + "GiftWrapEventStrategy execute invalid gift wrap — p tag rejects when there are multiple p tags" + ] + }, + { + "file": "test/unit/handlers/event-strategies/gift-wrap-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::gift_wrap_accepts_a_p_tag_with_a_relay_hint", + "titles": [ + "GiftWrapEventStrategy execute invalid gift wrap — p tag accepts p tag with an optional relay hint" + ] + }, + { + "file": "test/unit/handlers/event-strategies/gift-wrap-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::gift_wrap_refuses_empty_content", + "titles": [ + "GiftWrapEventStrategy execute invalid gift wrap — content format rejects when content is empty" + ] + }, + { + "file": "test/unit/handlers/event-strategies/gift-wrap-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::gift_wrap_refuses_plain_text_content", + "titles": [ + "GiftWrapEventStrategy execute invalid gift wrap — content format rejects when content is plain text instead of a NIP-44 payload" + ] + }, + { + "file": "test/unit/handlers/event-strategies/gift-wrap-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::gift_wrap_refuses_a_hash_prefixed_content", + "titles": [ + "GiftWrapEventStrategy execute invalid gift wrap — content format rejects when content signals an unsupported version (#)" + ] + }, + { + "file": "test/unit/handlers/event-strategies/group-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::group_stores_a_valid_group_event", + "titles": [ + "GroupEventStrategy execute valid group event creates the event in the repository" + ] + }, + { + "file": "test/unit/handlers/event-strategies/group-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::group_sends_ok_and_broadcasts_when_new", + "titles": [ + "GroupEventStrategy execute valid group event sends OK and broadcasts when the event is new" + ] + }, + { + "file": "test/unit/handlers/event-strategies/group-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::group_answers_duplicate_without_broadcasting", + "titles": [ + "GroupEventStrategy execute valid group event sends OK with duplicate marker and does not broadcast when event already exists" + ] + }, + { + "file": "test/unit/handlers/event-strategies/group-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::group_accepts_an_expiration_tag_alongside_the_h_tag", + "titles": [ + "GroupEventStrategy execute valid group event accepts an optional expiration tag alongside the h tag" + ] + }, + { + "file": "test/unit/handlers/event-strategies/group-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::group_refuses_a_missing_h_tag", + "titles": [ + "GroupEventStrategy execute invalid group event — h tag missing rejects when the h tag is absent" + ] + }, + { + "file": "test/unit/handlers/event-strategies/group-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::group_refuses_a_non_h_tag", + "titles": [ + "GroupEventStrategy execute invalid group event — h tag missing rejects when the only tag is not an h tag" + ] + }, + { + "file": "test/unit/handlers/event-strategies/group-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::group_refuses_two_h_tags", + "titles": [ + "GroupEventStrategy execute invalid group event — multiple h tags rejects when there are two h tags" + ] + }, + { + "file": "test/unit/handlers/event-strategies/group-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::group_refuses_a_short_group_id", + "titles": [ + "GroupEventStrategy execute invalid group event — h tag value format rejects when the group id is too short" + ] + }, + { + "file": "test/unit/handlers/event-strategies/group-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::group_refuses_a_long_group_id", + "titles": [ + "GroupEventStrategy execute invalid group event — h tag value format rejects when the group id is too long" + ] + }, + { + "file": "test/unit/handlers/event-strategies/group-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::group_refuses_uppercase_hex", + "titles": [ + "GroupEventStrategy execute invalid group event — h tag value format rejects when the group id contains uppercase hex chars" + ] + }, + { + "file": "test/unit/handlers/event-strategies/group-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::group_refuses_a_non_hex_group_id", + "titles": [ + "GroupEventStrategy execute invalid group event — h tag value format rejects when the group id contains non-hex characters" + ] + }, + { + "file": "test/unit/handlers/event-strategies/group-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::group_accepts_lowercase_hex", + "titles": [ + "GroupEventStrategy execute invalid group event — h tag value format accepts all valid lowercase hex characters (0-9 and a-f)" + ] + }, + { + "file": "test/unit/handlers/event-strategies/invite-request-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::invite_request_refuses_a_client_published_request", + "titles": [ + "InviteRequestEventStrategy rejects a client-published invite request" + ] + }, + { + "file": "test/unit/handlers/event-strategies/invite-request-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::invite_request_never_broadcasts", + "titles": [ + "InviteRequestEventStrategy never broadcasts the event" + ] + }, + { + "file": "test/unit/handlers/event-strategies/join-request-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::join_request_refuses_when_nip43_is_disabled", + "titles": [ + "JoinRequestEventStrategy rejects when NIP-43 is disabled" + ] + }, + { + "file": "test/unit/handlers/event-strategies/join-request-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::join_request_refuses_a_stale_created_at", + "titles": [ + "JoinRequestEventStrategy rejects requests with stale created_at" + ] + }, + { + "file": "test/unit/handlers/event-strategies/join-request-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::join_request_refuses_an_unauthenticated_request", + "titles": [ + "JoinRequestEventStrategy rejects unauthenticated requests with auth-required prefix" + ] + }, + { + "file": "test/unit/handlers/event-strategies/join-request-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::join_request_refuses_a_request_without_a_claim_tag", + "titles": [ + "JoinRequestEventStrategy rejects events missing the claim tag" + ] + }, + { + "file": "test/unit/handlers/event-strategies/join-request-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::join_request_answers_duplicate_when_already_admitted", + "titles": [ + "JoinRequestEventStrategy returns duplicate when user is already admitted" + ] + }, + { + "file": "test/unit/handlers/event-strategies/join-request-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::join_request_refuses_an_unusable_invite_code", + "titles": [ + "JoinRequestEventStrategy rejects when invite code is invalid or expired" + ] + }, + { + "file": "test/unit/handlers/event-strategies/join-request-event-strategy.spec.ts", + "exempt": "the admission cache and its invalidation are not ported; job 010 owns the payments surface — job 011", + "titles": [ + "JoinRequestEventStrategy admits user and invalidates admission cache on valid claim", + "JoinRequestEventStrategy still admits user when cache invalidation fails" + ] + }, + { + "file": "test/unit/handlers/event-strategies/leave-request-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::leave_request_refuses_when_nip43_is_disabled", + "titles": [ + "LeaveRequestEventStrategy rejects when NIP-43 is disabled" + ] + }, + { + "file": "test/unit/handlers/event-strategies/leave-request-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::leave_request_refuses_a_request_without_the_protected_tag", + "titles": [ + "LeaveRequestEventStrategy rejects leave requests missing the NIP-70 \"-\" tag" + ] + }, + { + "file": "test/unit/handlers/event-strategies/leave-request-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::leave_request_refuses_a_stale_created_at", + "titles": [ + "LeaveRequestEventStrategy rejects requests with stale created_at" + ] + }, + { + "file": "test/unit/handlers/event-strategies/leave-request-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::leave_request_refuses_an_unauthenticated_request", + "titles": [ + "LeaveRequestEventStrategy rejects unauthenticated requests with auth-required prefix" + ] + }, + { + "file": "test/unit/handlers/event-strategies/leave-request-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::leave_request_answers_ok_when_the_user_is_unknown", + "titles": [ + "LeaveRequestEventStrategy succeeds with no-op when user is not currently admitted" + ] + }, + { + "file": "test/unit/handlers/event-strategies/leave-request-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::leave_request_answers_ok_when_the_user_is_not_admitted", + "titles": [ + "LeaveRequestEventStrategy succeeds with no-op when user exists but is not admitted" + ] + }, + { + "file": "test/unit/handlers/event-strategies/leave-request-event-strategy.spec.ts", + "exempt": "the admission cache and its invalidation are not ported; job 010 owns the payments surface — job 011", + "titles": [ + "LeaveRequestEventStrategy revokes admission and invalidates admission cache", + "LeaveRequestEventStrategy still revokes admission when cache invalidation fails" + ] + }, + { + "file": "test/unit/handlers/event-strategies/parameterized-replaceable-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::parameterized_replaceable_uses_an_empty_deduplication_key_without_a_d_tag", + "titles": [ + "ParameterizedReplaceableEventStrategy execute upserts event without d tag" + ] + }, + { + "file": "test/unit/handlers/event-strategies/parameterized-replaceable-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::parameterized_replaceable_uses_the_first_d_tag_value", + "titles": [ + "ParameterizedReplaceableEventStrategy execute upserts event with d tag and one string" + ] + }, + { + "file": "test/unit/handlers/event-strategies/parameterized-replaceable-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::parameterized_replaceable_uses_only_the_first_d_tag_element", + "titles": [ + "ParameterizedReplaceableEventStrategy execute upserts event with d tag and two strings" + ] + }, + { + "file": "test/unit/handlers/event-strategies/parameterized-replaceable-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::parameterized_replaceable_broadcasts_when_created", + "titles": [ + "ParameterizedReplaceableEventStrategy execute broadcast event if event is created" + ] + }, + { + "file": "test/unit/handlers/event-strategies/parameterized-replaceable-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::parameterized_replaceable_does_not_broadcast_a_duplicate", + "titles": [ + "ParameterizedReplaceableEventStrategy execute does not broadcast event if event is duplicate" + ] + }, + { + "file": "test/unit/handlers/event-strategies/parameterized-replaceable-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::parameterized_replaceable_returns_an_error_when_the_upsert_fails", + "titles": [ + "ParameterizedReplaceableEventStrategy execute rejects if unable to upsert event" + ] + }, + { + "file": "test/unit/handlers/event-strategies/replaceable-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::replaceable_upserts_the_event", + "titles": [ + "ReplaceableEventStrategy execute upserts event" + ] + }, + { + "file": "test/unit/handlers/event-strategies/replaceable-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::replaceable_broadcasts_when_new", + "titles": [ + "ReplaceableEventStrategy execute broadcast event if event is created" + ] + }, + { + "file": "test/unit/handlers/event-strategies/replaceable-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::replaceable_does_not_broadcast_when_the_stored_row_wins", + "titles": [ + "ReplaceableEventStrategy execute does not broadcast event if event is duplicate" + ] + }, + { + "file": "test/unit/handlers/event-strategies/replaceable-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::replaceable_returns_an_error_on_an_unexpected_upsert_failure", + "titles": [ + "ReplaceableEventStrategy execute rejects if unable to upsert event" + ] + }, + { + "file": "test/unit/handlers/event-strategies/report-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::report_creates_the_event", + "titles": [ + "ReportEventStrategy execute creates the event" + ] + }, + { + "file": "test/unit/handlers/event-strategies/report-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::report_broadcasts_the_event_when_new", + "titles": [ + "ReportEventStrategy execute broadcasts the event when newly created" + ] + }, + { + "file": "test/unit/handlers/event-strategies/report-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::report_does_not_broadcast_or_record_a_duplicate", + "titles": [ + "ReportEventStrategy execute does not broadcast or record a report when the event is a duplicate" + ] + }, + { + "file": "test/unit/handlers/event-strategies/report-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::report_records_full_weight_for_a_direct_follow", + "titles": [ + "ReportEventStrategy execute records a report with full weight for a direct follow (distance 1)" + ] + }, + { + "file": "test/unit/handlers/event-strategies/report-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::report_records_zero_weight_outside_the_graph", + "titles": [ + "ReportEventStrategy execute records a report with zero weight for a reporter outside the trust graph" + ] + }, + { + "file": "test/unit/handlers/event-strategies/report-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::report_records_a_max_weight_actionable_row_for_a_moderator", + "titles": [ + "ReportEventStrategy execute records an actionable, max-weight report from a trusted moderator regardless of distance" + ] + }, + { + "file": "test/unit/handlers/event-strategies/report-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::report_marks_the_target_hidden_when_hiding_is_enabled", + "titles": [ + "ReportEventStrategy execute marks the target in the hidden-content cache when hideActionableReports is enabled" + ] + }, + { + "file": "test/unit/handlers/event-strategies/report-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::report_does_not_mark_the_target_when_hiding_is_disabled", + "titles": [ + "ReportEventStrategy execute does not mark the cache when hideActionableReports is disabled" + ] + }, + { + "file": "test/unit/handlers/event-strategies/report-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::report_does_not_mark_the_target_for_a_non_moderator", + "titles": [ + "ReportEventStrategy execute does not mark the cache for a non-actionable (non-moderator) report even when hideActionableReports is enabled" + ] + }, + { + "file": "test/unit/handlers/event-strategies/report-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::report_does_not_consult_the_graph_for_a_moderator", + "titles": [ + "ReportEventStrategy execute does not consult the WoT graph for a trusted moderator" + ] + }, + { + "file": "test/unit/handlers/event-strategies/report-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::report_records_nothing_without_a_valid_target", + "titles": [ + "ReportEventStrategy execute does not record any report when the event has no valid target" + ] + }, + { + "file": "test/unit/handlers/event-strategies/report-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::report_records_nothing_for_a_moderator_without_a_valid_target", + "titles": [ + "ReportEventStrategy execute does not record any report for a moderator event with no valid target" + ] + }, + { + "file": "test/unit/handlers/event-strategies/report-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::report_records_one_row_per_target_in_one_batch", + "titles": [ + "ReportEventStrategy execute records one row per target, in a single batch, when p and e tags carry different report types" + ] + }, + { + "file": "test/unit/handlers/event-strategies/report-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::report_stores_the_event_without_scoring_when_nip56_is_off", + "titles": [ + "ReportEventStrategy execute stores the event but does not record a report when nip56 is disabled" + ] + }, + { + "file": "test/unit/handlers/event-strategies/report-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::report_does_not_reject_the_event_when_recording_fails", + "titles": [ + "ReportEventStrategy execute does not reject the event when report recording fails" + ] + }, + { + "file": "test/unit/handlers/event-strategies/report-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::report_returns_an_error_when_the_create_fails", + "titles": [ + "ReportEventStrategy execute rejects if unable to create the event" + ] + }, + { + "file": "test/unit/handlers/event-strategies/timestamp-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::timestamp_stores_and_broadcasts_a_valid_proof", + "titles": [ + "TimestampEventStrategy valid opentimestamps event stores and broadcasts the event" + ] + }, + { + "file": "test/unit/handlers/event-strategies/timestamp-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::timestamp_answers_duplicate_without_broadcasting", + "titles": [ + "TimestampEventStrategy valid opentimestamps event emits a duplicate OK without broadcasting when the event already exists" + ] + }, + { + "file": "test/unit/handlers/event-strategies/timestamp-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::timestamp_accepts_an_event_without_a_k_tag", + "titles": [ + "TimestampEventStrategy valid opentimestamps event accepts an event without a k tag" + ] + }, + { + "file": "test/unit/handlers/event-strategies/timestamp-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::timestamp_refuses_a_missing_e_tag", + "titles": [ + "TimestampEventStrategy invalid opentimestamps event rejects when the e tag is missing" + ] + }, + { + "file": "test/unit/handlers/event-strategies/timestamp-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::timestamp_refuses_multiple_e_tags", + "titles": [ + "TimestampEventStrategy invalid opentimestamps event rejects when multiple e tags are present" + ] + }, + { + "file": "test/unit/handlers/event-strategies/timestamp-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::timestamp_refuses_a_malformed_e_tag", + "titles": [ + "TimestampEventStrategy invalid opentimestamps event rejects when the e tag value is not a 32-byte hex id" + ] + }, + { + "file": "test/unit/handlers/event-strategies/timestamp-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::timestamp_refuses_uppercase_hex_in_the_e_tag", + "titles": [ + "TimestampEventStrategy invalid opentimestamps event rejects upper-case hex in the e tag (NIP-01 requires lowercase)" + ] + }, + { + "file": "test/unit/handlers/event-strategies/timestamp-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::timestamp_refuses_a_non_integer_k_tag", + "titles": [ + "TimestampEventStrategy invalid opentimestamps event rejects a non-integer k tag" + ] + }, + { + "file": "test/unit/handlers/event-strategies/timestamp-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::timestamp_refuses_a_negative_k_tag", + "titles": [ + "TimestampEventStrategy invalid opentimestamps event rejects a negative k tag" + ] + }, + { + "file": "test/unit/handlers/event-strategies/timestamp-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::timestamp_refuses_multiple_k_tags", + "titles": [ + "TimestampEventStrategy invalid opentimestamps event rejects when multiple k tags are present" + ] + }, + { + "file": "test/unit/handlers/event-strategies/timestamp-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::timestamp_refuses_a_mismatched_ots_digest", + "titles": [ + "TimestampEventStrategy invalid opentimestamps event rejects when the OTS digest does not match the e-tagged event id" + ] + }, + { + "file": "test/unit/handlers/event-strategies/timestamp-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::timestamp_refuses_content_that_is_not_an_ots_proof", + "titles": [ + "TimestampEventStrategy invalid opentimestamps event rejects when the content is not a valid OTS proof" + ] + }, + { + "file": "test/unit/handlers/event-strategies/timestamp-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::timestamp_refuses_empty_content", + "titles": [ + "TimestampEventStrategy invalid opentimestamps event rejects when the content is empty" + ] + }, + { + "file": "test/unit/handlers/event-strategies/vanish-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::vanish_soft_deletes_the_other_events_and_stores_the_request", + "titles": [ + "VanishEventStrategy deletes all events for pubkey except kind 62 events and creates the vanish event" + ] + }, + { + "file": "test/unit/handlers/event-strategies/vanish-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::vanish_never_broadcasts", + "titles": [ + "VanishEventStrategy does not broadcast the vanish event" + ] + }, + { + "file": "test/unit/handlers/event-strategies/vanish-event-strategy.spec.ts", + "test": "crates/nostream-relay/tests/strategies.rs::vanish_answers_duplicate_when_the_request_exists", + "titles": [ + "VanishEventStrategy returns duplicate OK if the event already exists" + ] + }, { "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", diff --git a/crates/nostream-relay/tests/fixtures/ts-cases.json b/crates/nostream-relay/tests/fixtures/ts-cases.json index e8ff1ccb2..2ec103706 100644 --- a/crates/nostream-relay/tests/fixtures/ts-cases.json +++ b/crates/nostream-relay/tests/fixtures/ts-cases.json @@ -1,7 +1,7 @@ { "capture": "242a3001d77149e4d70151ec03060c4ef44a1547", "generated_by": "crates/nostream-relay/tests/capture/gen-ts-cases.mjs", - "total": 557, + "total": 658, "files": [ { "file": "test/unit/handlers/event-message-handler.spec.ts", @@ -314,6 +314,19 @@ "UnsubscribeMessageHandler handleMessage() emits unsubscribe event with subscription Id" ] }, + { + "file": "test/unit/handlers/event-strategies/contact-list-event-strategy.spec.ts", + "cases": 7, + "titles": [ + "ContactListEventStrategy execute upserts the event", + "ContactListEventStrategy execute updates the wot graph with the p-tagged pubkeys when newly stored", + "ContactListEventStrategy execute broadcasts the event when newly stored", + "ContactListEventStrategy execute does not broadcast or update the wot graph when the event is a duplicate", + "ContactListEventStrategy execute does not fail the event when the wot graph update fails", + "ContactListEventStrategy execute emits a rejection when the event already exists", + "ContactListEventStrategy execute emits an error message on unexpected upsert failure" + ] + }, { "file": "test/unit/handlers/event-strategies/default-event-strategy.spec.ts", "cases": 4, @@ -324,6 +337,178 @@ "DefaultEventStrategy execute rejects if unable to create event" ] }, + { + "file": "test/unit/handlers/event-strategies/delete-event-strategy.spec.ts", + "cases": 6, + "titles": [ + "DeleteEventStrategy execute creates event", + "DeleteEventStrategy execute deletes events if it has e tags", + "DeleteEventStrategy execute does not delete events if there are no e tags", + "DeleteEventStrategy execute broadcast event if created", + "DeleteEventStrategy execute does not broadcast event if duplicate", + "DeleteEventStrategy execute rejects if unable to create event" + ] + }, + { + "file": "test/unit/handlers/event-strategies/dvm-job-request-event-strategy.spec.ts", + "cases": 6, + "titles": [ + "DvmJobRequestEventStrategy execute creates the event", + "DvmJobRequestEventStrategy execute records a dvm job when the event is newly created", + "DvmJobRequestEventStrategy execute broadcasts the event when newly created", + "DvmJobRequestEventStrategy execute does not broadcast or record a job when the event is a duplicate", + "DvmJobRequestEventStrategy execute does not reject the event when job recording fails", + "DvmJobRequestEventStrategy execute rejects if unable to create the event" + ] + }, + { + "file": "test/unit/handlers/event-strategies/ephemeral-event-strategy.spec.ts", + "cases": 1, + "titles": [ + "EphemeralEventStrategy execute broadcasts event" + ] + }, + { + "file": "test/unit/handlers/event-strategies/gift-wrap-event-strategy.spec.ts", + "cases": 9, + "titles": [ + "GiftWrapEventStrategy execute valid gift wrap creates the event in the repository", + "GiftWrapEventStrategy execute valid gift wrap sends OK and broadcasts when the event is new", + "GiftWrapEventStrategy execute valid gift wrap sends OK with duplicate marker and does not broadcast when event already exists", + "GiftWrapEventStrategy execute invalid gift wrap — p tag rejects when the p tag is missing", + "GiftWrapEventStrategy execute invalid gift wrap — p tag rejects when there are multiple p tags", + "GiftWrapEventStrategy execute invalid gift wrap — p tag accepts p tag with an optional relay hint", + "GiftWrapEventStrategy execute invalid gift wrap — content format rejects when content is empty", + "GiftWrapEventStrategy execute invalid gift wrap — content format rejects when content is plain text instead of a NIP-44 payload", + "GiftWrapEventStrategy execute invalid gift wrap — content format rejects when content signals an unsupported version (#)" + ] + }, + { + "file": "test/unit/handlers/event-strategies/group-event-strategy.spec.ts", + "cases": 12, + "titles": [ + "GroupEventStrategy execute valid group event creates the event in the repository", + "GroupEventStrategy execute valid group event sends OK and broadcasts when the event is new", + "GroupEventStrategy execute valid group event sends OK with duplicate marker and does not broadcast when event already exists", + "GroupEventStrategy execute valid group event accepts an optional expiration tag alongside the h tag", + "GroupEventStrategy execute invalid group event — h tag missing rejects when the h tag is absent", + "GroupEventStrategy execute invalid group event — h tag missing rejects when the only tag is not an h tag", + "GroupEventStrategy execute invalid group event — multiple h tags rejects when there are two h tags", + "GroupEventStrategy execute invalid group event — h tag value format rejects when the group id is too short", + "GroupEventStrategy execute invalid group event — h tag value format rejects when the group id is too long", + "GroupEventStrategy execute invalid group event — h tag value format rejects when the group id contains uppercase hex chars", + "GroupEventStrategy execute invalid group event — h tag value format rejects when the group id contains non-hex characters", + "GroupEventStrategy execute invalid group event — h tag value format accepts all valid lowercase hex characters (0-9 and a-f)" + ] + }, + { + "file": "test/unit/handlers/event-strategies/invite-request-event-strategy.spec.ts", + "cases": 2, + "titles": [ + "InviteRequestEventStrategy rejects a client-published invite request", + "InviteRequestEventStrategy never broadcasts the event" + ] + }, + { + "file": "test/unit/handlers/event-strategies/join-request-event-strategy.spec.ts", + "cases": 8, + "titles": [ + "JoinRequestEventStrategy rejects when NIP-43 is disabled", + "JoinRequestEventStrategy rejects requests with stale created_at", + "JoinRequestEventStrategy rejects unauthenticated requests with auth-required prefix", + "JoinRequestEventStrategy rejects events missing the claim tag", + "JoinRequestEventStrategy returns duplicate when user is already admitted", + "JoinRequestEventStrategy rejects when invite code is invalid or expired", + "JoinRequestEventStrategy admits user and invalidates admission cache on valid claim", + "JoinRequestEventStrategy still admits user when cache invalidation fails" + ] + }, + { + "file": "test/unit/handlers/event-strategies/leave-request-event-strategy.spec.ts", + "cases": 8, + "titles": [ + "LeaveRequestEventStrategy rejects when NIP-43 is disabled", + "LeaveRequestEventStrategy rejects leave requests missing the NIP-70 \"-\" tag", + "LeaveRequestEventStrategy rejects requests with stale created_at", + "LeaveRequestEventStrategy rejects unauthenticated requests with auth-required prefix", + "LeaveRequestEventStrategy succeeds with no-op when user is not currently admitted", + "LeaveRequestEventStrategy succeeds with no-op when user exists but is not admitted", + "LeaveRequestEventStrategy revokes admission and invalidates admission cache", + "LeaveRequestEventStrategy still revokes admission when cache invalidation fails" + ] + }, + { + "file": "test/unit/handlers/event-strategies/parameterized-replaceable-event-strategy.spec.ts", + "cases": 6, + "titles": [ + "ParameterizedReplaceableEventStrategy execute upserts event without d tag", + "ParameterizedReplaceableEventStrategy execute upserts event with d tag and one string", + "ParameterizedReplaceableEventStrategy execute upserts event with d tag and two strings", + "ParameterizedReplaceableEventStrategy execute broadcast event if event is created", + "ParameterizedReplaceableEventStrategy execute does not broadcast event if event is duplicate", + "ParameterizedReplaceableEventStrategy execute rejects if unable to upsert event" + ] + }, + { + "file": "test/unit/handlers/event-strategies/replaceable-event-strategy.spec.ts", + "cases": 4, + "titles": [ + "ReplaceableEventStrategy execute upserts event", + "ReplaceableEventStrategy execute broadcast event if event is created", + "ReplaceableEventStrategy execute does not broadcast event if event is duplicate", + "ReplaceableEventStrategy execute rejects if unable to upsert event" + ] + }, + { + "file": "test/unit/handlers/event-strategies/report-event-strategy.spec.ts", + "cases": 16, + "titles": [ + "ReportEventStrategy execute creates the event", + "ReportEventStrategy execute broadcasts the event when newly created", + "ReportEventStrategy execute does not broadcast or record a report when the event is a duplicate", + "ReportEventStrategy execute records a report with full weight for a direct follow (distance 1)", + "ReportEventStrategy execute records a report with zero weight for a reporter outside the trust graph", + "ReportEventStrategy execute records an actionable, max-weight report from a trusted moderator regardless of distance", + "ReportEventStrategy execute marks the target in the hidden-content cache when hideActionableReports is enabled", + "ReportEventStrategy execute does not mark the cache when hideActionableReports is disabled", + "ReportEventStrategy execute does not mark the cache for a non-actionable (non-moderator) report even when hideActionableReports is enabled", + "ReportEventStrategy execute does not consult the WoT graph for a trusted moderator", + "ReportEventStrategy execute does not record any report when the event has no valid target", + "ReportEventStrategy execute does not record any report for a moderator event with no valid target", + "ReportEventStrategy execute records one row per target, in a single batch, when p and e tags carry different report types", + "ReportEventStrategy execute stores the event but does not record a report when nip56 is disabled", + "ReportEventStrategy execute does not reject the event when report recording fails", + "ReportEventStrategy execute rejects if unable to create the event" + ] + }, + { + "file": "test/unit/handlers/event-strategies/timestamp-event-strategy.spec.ts", + "cases": 13, + "titles": [ + "TimestampEventStrategy valid opentimestamps event stores and broadcasts the event", + "TimestampEventStrategy valid opentimestamps event emits a duplicate OK without broadcasting when the event already exists", + "TimestampEventStrategy valid opentimestamps event accepts an event without a k tag", + "TimestampEventStrategy invalid opentimestamps event rejects when the e tag is missing", + "TimestampEventStrategy invalid opentimestamps event rejects when multiple e tags are present", + "TimestampEventStrategy invalid opentimestamps event rejects when the e tag value is not a 32-byte hex id", + "TimestampEventStrategy invalid opentimestamps event rejects upper-case hex in the e tag (NIP-01 requires lowercase)", + "TimestampEventStrategy invalid opentimestamps event rejects a non-integer k tag", + "TimestampEventStrategy invalid opentimestamps event rejects a negative k tag", + "TimestampEventStrategy invalid opentimestamps event rejects when multiple k tags are present", + "TimestampEventStrategy invalid opentimestamps event rejects when the OTS digest does not match the e-tagged event id", + "TimestampEventStrategy invalid opentimestamps event rejects when the content is not a valid OTS proof", + "TimestampEventStrategy invalid opentimestamps event rejects when the content is empty" + ] + }, + { + "file": "test/unit/handlers/event-strategies/vanish-event-strategy.spec.ts", + "cases": 3, + "titles": [ + "VanishEventStrategy deletes all events for pubkey except kind 62 events and creates the vanish event", + "VanishEventStrategy does not broadcast the vanish event", + "VanishEventStrategy returns duplicate OK if the event already exists" + ] + }, { "file": "test/unit/adapters/web-socket-adapter.spec.ts", "cases": 60, diff --git a/crates/nostream-relay/tests/strategies.rs b/crates/nostream-relay/tests/strategies.rs new file mode 100644 index 000000000..83cc663f9 --- /dev/null +++ b/crates/nostream-relay/tests/strategies.rs @@ -0,0 +1,2230 @@ +//! The fourteen event strategies, one test per case of the capture's fourteen spec files. +//! +//! Each test drives its arm directly — `::…Strategy.execute(&event, &context)` over a +//! [`Fixture`] whose sink is a [`FrameSink::channel`], so the frames it sent are readable — and +//! asserts an observable: the frames in order, the broadcast hub, or a recorded interaction with +//! its argument. No test opens a socket and no test needs a database. +//! +//! The capture's cases build events of the shape `{id: 'id', pubkey: 'pubkey'}` +//! (`delete-event-strategy.spec.ts:21-28`), which a Rust [`Event`] cannot hold. Each case is +//! rebuilt as a real event with 32-byte ids and the tags it needs: the census maps titles, not +//! case data. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::Arc; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use nostream_config::settings::{Nip56Settings, Settings}; +use nostream_core::clock::TestClock; +use nostream_core::event::{EventId, Pubkey, Signature}; +use nostream_core::messages::{CommandResult, RelayMessage}; +use nostream_core::nip42::Nip42Session; +use nostream_core::{Event, Kind, SettingsPubkey}; +use nostream_relay::connection::Connection; +use nostream_relay::error::RelayError; +use nostream_relay::handlers::HandlerContext; +use nostream_relay::relay_key::RelayKeySource; +use nostream_relay::sink::FrameSink; +use nostream_relay::strategies::EventStrategy; +use nostream_relay::strategies::contact_list::ContactListStrategy; +use nostream_relay::strategies::delete::DeleteStrategy; +use nostream_relay::strategies::dvm_job_request::DvmJobRequestStrategy; +use nostream_relay::strategies::ephemeral::EphemeralStrategy; +use nostream_relay::strategies::gift_wrap::GiftWrapStrategy; +use nostream_relay::strategies::group::GroupStrategy; +use nostream_relay::strategies::invite_request::InviteRequestStrategy; +use nostream_relay::strategies::join_request::JoinRequestStrategy; +use nostream_relay::strategies::leave_request::LeaveRequestStrategy; +use nostream_relay::strategies::parameterized_replaceable::ParameterizedReplaceableStrategy; +use nostream_relay::strategies::replaceable::ReplaceableStrategy; +use nostream_relay::strategies::report::ReportStrategy; +use nostream_relay::strategies::timestamp::TimestampStrategy; +use nostream_relay::strategies::vanish::VanishStrategy; +use nostream_relay::testing::{Fixture, UpsertFailure, hub_receiver, nip43_settings, settings}; +use nostream_relay::unavailable::UnavailableWotGraph; +use nostream_storage::repositories::{ + CreateInviteCodeOptions, EventRecord, EventRepository, FieldUpdate, InviteCodeRepository, + NewReport, ReportType, User, UserRepository, +}; +use serde_json::json; +use tokio::sync::{broadcast, mpsc}; + +/// The instant every case runs at: 2023-11-14T22:13:20Z. +const NOW_SECS: i64 = 1_700_000_000; + +/// The milliseconds of [`NOW_SECS`]. +const NOW_MS: u64 = 1_700_000_000_000; + +/// Another event id, for a case that needs a second one. The bytes spell hex letters, so a +/// case can also ask for its uppercase form. +fn other_event_id() -> EventId { + EventId::from_byte_array([0xab; 32]) +} + +/// The pubkey every case signs with. +fn author() -> Pubkey { + Pubkey::from_byte_array([0x22; 32]) +} + +/// `value` repeated to `width` hex characters. +fn hex(value: u8, width: usize) -> String { + format!("{value:02x}").repeat(width / 2) +} + +fn tag(parts: &[&str]) -> Vec { + parts.iter().map(|part| (*part).to_owned()).collect() +} + +/// A well-typed event at [`NOW_SECS`]. +fn event(kind: u64, tags: Vec>, content: &str) -> Event { + Event { + id: EventId::from_byte_array([0x11; 32]), + pubkey: author(), + created_at: NOW_SECS, + kind: Kind(kind), + tags, + content: content.to_owned(), + sig: Signature::from_byte_array([0x33; 64]), + } +} + +fn clock() -> Arc { + Arc::new(TestClock::new(NOW_MS)) +} + +fn keys() -> RelayKeySource { + RelayKeySource::from_values(None, None) +} + +/// A context whose sink a test can read, with its frames. +fn socket(fixture: &Fixture) -> (HandlerContext, mpsc::UnboundedReceiver) { + let (sink, frames) = FrameSink::channel(64 * 1024); + let connection = Arc::new(Connection::new( + "127.0.0.1".to_owned(), + Nip42Session::with_challenge("challenge", 0), + )); + + (fixture.context(clock(), keys(), connection, sink), frames) +} + +/// The same, with the socket authenticated as `pubkey`. +fn authenticated_socket( + fixture: &Fixture, + pubkey: Pubkey, +) -> (HandlerContext, mpsc::UnboundedReceiver) { + let (context, frames) = socket(fixture); + + context.connection.authenticate( + pubkey, + EventId::from_byte_array([0x09; 32]), + context.now_secs(), + ); + + (context, frames) +} + +/// Every frame the sink queued, in order. +fn frames(receiver: &mut mpsc::UnboundedReceiver) -> Vec { + let mut queued = Vec::new(); + + while let Ok(frame) = receiver.try_recv() { + queued.push(frame); + } + + queued +} + +/// The `OK` frame `HandlerContext::send_ok` renders, through the same encoder. +fn ok_frame(event: &Event, accepted: bool, message: &str) -> String { + RelayMessage::Ok(CommandResult { + event_id: event.id, + accepted, + message: message.to_owned(), + params: None, + }) + .to_text() +} + +/// The event the hub queued, if any. +fn broadcast(hub: &mut broadcast::Receiver>) -> Option { + hub.try_recv().ok().map(|event| (*event).clone()) +} + +/// Settings with NIP-43 enabled. +fn nip43_on() -> Settings { + let mut settings = settings(); + settings.nip43 = Some(nip43_settings()); + + settings +} + +/// Settings with NIP-56 on and the given trusted moderators. +fn nip56_on(trusted_moderators: Vec, hide_actionable_reports: bool) -> Settings { + let mut settings = settings(); + settings.nip56 = Some(Nip56Settings { + enabled: true, + trusted_moderators: trusted_moderators + .into_iter() + .map(|pubkey| SettingsPubkey(pubkey.to_string())) + .collect(), + hide_actionable_reports, + }); + + settings +} + +/// A structurally valid NIP-44 v2 payload: version byte 2 and 98 more bytes, base64. +fn nip44_payload() -> String { + let mut bytes = vec![2u8]; + bytes.resize(99, 0); + + STANDARD.encode(bytes) +} + +// --------------------------------------------------------------------------- +// contact_list — `contact-list-event-strategy.spec.ts` +// --------------------------------------------------------------------------- + +/// The capture's kind-3 fixture: two follows and a tag that is not one. +fn contact_list_event() -> Event { + event( + 3, + vec![ + tag(&["p", &hex(0xaa, 64)]), + tag(&["p", &hex(0xbb, 64)]), + tag(&["e", &hex(0xcc, 64)]), + ], + "", + ) +} + +#[tokio::test] +async fn contact_list_upserts_the_event() { + let fixture = Fixture::new(settings()); + let context = fixture.anonymous_context(clock(), keys()); + let event = contact_list_event(); + + ContactListStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!(fixture.events.events(), vec![event.clone()]); + assert_eq!( + fixture.events.upserts(), + vec![vec![json!(author().to_string()), json!(3)]] + ); +} + +#[tokio::test] +async fn contact_list_updates_the_wot_graph_with_the_p_tagged_pubkeys_when_newly_stored() { + let fixture = Fixture::new(settings()); + let context = fixture.anonymous_context(clock(), keys()); + + ContactListStrategy + .execute(&contact_list_event(), &context) + .await + .unwrap(); + + assert_eq!( + fixture.wot.follow_updates(), + vec![(author(), vec![hex(0xaa, 64), hex(0xbb, 64)])] + ); +} + +#[tokio::test] +async fn contact_list_broadcasts_the_event_when_newly_stored() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut hub = hub_receiver(&fixture.hub); + let event = contact_list_event(); + + ContactListStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "")]); + assert_eq!(broadcast(&mut hub), Some(event)); +} + +#[tokio::test] +async fn contact_list_does_not_broadcast_or_update_the_wot_graph_when_the_event_is_a_duplicate() { + let fixture = Fixture::new(settings()); + let event = contact_list_event(); + fixture.seed_event(event.clone()).await; + + let (context, mut sink) = socket(&fixture); + let mut hub = hub_receiver(&fixture.hub); + + ContactListStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame(&event, true, "duplicate:")] + ); + assert_eq!(broadcast(&mut hub), None); + assert_eq!(fixture.wot.follow_updates(), Vec::new()); +} + +#[tokio::test] +async fn contact_list_does_not_fail_the_event_when_the_wot_graph_update_fails() { + let fixture = Fixture::new(settings()); + fixture.wot.fail_updates(); + + let (context, mut sink) = socket(&fixture); + let event = contact_list_event(); + + ContactListStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "")]); + assert_eq!(fixture.wot.follow_updates().len(), 1); +} + +#[tokio::test] +async fn contact_list_emits_a_rejection_when_the_event_already_exists() { + let fixture = Fixture::new(settings()); + fixture.events.fail_upsert(UpsertFailure::DuplicateEvent); + + let (context, mut sink) = socket(&fixture); + let mut hub = hub_receiver(&fixture.hub); + let event = contact_list_event(); + + ContactListStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame(&event, false, "rejected: event already exists")] + ); + assert_eq!(fixture.wot.follow_updates(), Vec::new()); + assert_eq!(broadcast(&mut hub), None); +} + +#[tokio::test] +async fn contact_list_returns_an_error_on_an_unexpected_upsert_failure() { + // The capture answers `error: `; the port's `StorageError` forbids forwarding + // backend text to a client, so the failure leaves `execute` and the handler answers + // `error: unable to process event` (ADR 0006, R2). + let fixture = Fixture::new(settings()); + fixture.events.fail_upsert(UpsertFailure::Unavailable); + + let (context, mut sink) = socket(&fixture); + let event = contact_list_event(); + let outcome = ContactListStrategy.execute(&event, &context).await; + + assert!(matches!(outcome, Err(RelayError::Storage(_)))); + assert_eq!(frames(&mut sink), Vec::::new()); +} + +// --------------------------------------------------------------------------- +// delete — `delete-event-strategy.spec.ts` +// --------------------------------------------------------------------------- + +/// The capture's kind-5 fixture: two 64-hex `e` tags. +fn delete_event() -> Event { + event( + 5, + vec![tag(&["e", &"0".repeat(64)]), tag(&["e", &"f".repeat(64)])], + "", + ) +} + +/// Two stored events of the author's, one per `e` tag of [`delete_event`]. +fn stored_targets() -> Vec { + vec![ + Event { + id: "0".repeat(64).parse().unwrap(), + ..event(1, Vec::new(), "first") + }, + Event { + id: "f".repeat(64).parse().unwrap(), + ..event(1, Vec::new(), "second") + }, + ] +} + +#[tokio::test] +async fn delete_creates_the_event() { + let fixture = Fixture::new(settings()); + let context = fixture.anonymous_context(clock(), keys()); + let event = delete_event(); + + DeleteStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!(fixture.events.events(), vec![event]); +} + +#[tokio::test] +async fn delete_soft_deletes_the_events_its_e_tags_name() { + let fixture = Fixture::new(settings()); + + for target in stored_targets() { + fixture.seed_event(target).await; + } + + let (context, mut sink) = socket(&fixture); + let event = delete_event(); + + DeleteStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + fixture.events.deletes(), + vec![( + author(), + vec![ + "0".repeat(64).parse().unwrap(), + "f".repeat(64).parse().unwrap() + ] + )] + ); + assert_eq!(fixture.events.live_events(), vec![event.clone()]); + assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "")]); +} + +#[tokio::test] +async fn delete_does_not_delete_when_there_are_no_e_tags() { + let fixture = Fixture::new(settings()); + let context = fixture.anonymous_context(clock(), keys()); + let mut event = delete_event(); + event.tags = Vec::new(); + + DeleteStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!(fixture.events.deletes(), Vec::new()); + assert_eq!(fixture.events.events(), vec![event]); +} + +#[tokio::test] +async fn delete_broadcasts_the_event_when_created() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut hub = hub_receiver(&fixture.hub); + let event = delete_event(); + + DeleteStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "")]); + assert_eq!(broadcast(&mut hub), Some(event)); +} + +#[tokio::test] +async fn delete_does_not_broadcast_when_the_event_is_a_duplicate() { + let fixture = Fixture::new(settings()); + let event = delete_event(); + fixture.seed_event(event.clone()).await; + + let (context, mut sink) = socket(&fixture); + let mut hub = hub_receiver(&fixture.hub); + + DeleteStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame(&event, true, "duplicate:")] + ); + assert_eq!(broadcast(&mut hub), None); +} + +#[tokio::test] +async fn delete_returns_an_error_when_the_create_fails() { + // The erase runs **before** the create, so a failed create leaves the deletions applied and + // nothing answered (the capture's order; the spec's R11 note). + let fixture = Fixture::new(settings()); + + for target in stored_targets() { + fixture.seed_event(target).await; + } + + fixture.events.fail_create(true); + + let (context, mut sink) = socket(&fixture); + let event = delete_event(); + let outcome = DeleteStrategy.execute(&event, &context).await; + + assert!(matches!(outcome, Err(RelayError::Storage(_)))); + assert_eq!(fixture.events.deletes().len(), 1); + assert_eq!(frames(&mut sink), Vec::::new()); +} + +// --------------------------------------------------------------------------- +// dvm_job_request — `dvm-job-request-event-strategy.spec.ts` +// --------------------------------------------------------------------------- + +fn dvm_event() -> Event { + event(5000, Vec::new(), "") +} + +#[tokio::test] +async fn dvm_job_request_creates_the_event() { + let fixture = Fixture::new(settings()); + let context = fixture.anonymous_context(clock(), keys()); + let event = dvm_event(); + + DvmJobRequestStrategy + .execute(&event, &context) + .await + .unwrap(); + + assert_eq!(fixture.events.events(), vec![event]); +} + +#[tokio::test] +async fn dvm_job_request_records_a_job_when_the_event_is_new() { + let fixture = Fixture::new(settings()); + let context = fixture.anonymous_context(clock(), keys()); + let event = dvm_event(); + + DvmJobRequestStrategy + .execute(&event, &context) + .await + .unwrap(); + + let jobs = fixture.dvm.jobs(); + + assert_eq!(jobs.len(), 1); + assert_eq!(jobs[0].id, event.id); + assert_eq!(jobs[0].requester_pubkey, author()); + assert_eq!(jobs[0].kind, Kind(5000)); +} + +#[tokio::test] +async fn dvm_job_request_broadcasts_the_event_when_new() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut hub = hub_receiver(&fixture.hub); + let event = dvm_event(); + + DvmJobRequestStrategy + .execute(&event, &context) + .await + .unwrap(); + + assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "")]); + assert_eq!(broadcast(&mut hub), Some(event)); +} + +#[tokio::test] +async fn dvm_job_request_does_not_broadcast_or_record_a_job_when_the_event_is_a_duplicate() { + let fixture = Fixture::new(settings()); + let event = dvm_event(); + fixture.seed_event(event.clone()).await; + + let (context, mut sink) = socket(&fixture); + let mut hub = hub_receiver(&fixture.hub); + + DvmJobRequestStrategy + .execute(&event, &context) + .await + .unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame(&event, true, "duplicate:")] + ); + assert_eq!(fixture.dvm.jobs().len(), 0); + assert_eq!(broadcast(&mut hub), None); +} + +#[tokio::test] +async fn dvm_job_request_does_not_reject_the_event_when_job_recording_fails() { + let fixture = Fixture::new(settings()); + fixture.dvm.fail_writes(); + + let (context, mut sink) = socket(&fixture); + let event = dvm_event(); + + DvmJobRequestStrategy + .execute(&event, &context) + .await + .unwrap(); + + assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "")]); + assert_eq!(fixture.dvm.jobs().len(), 0); +} + +#[tokio::test] +async fn dvm_job_request_returns_an_error_when_the_create_fails() { + let fixture = Fixture::new(settings()); + fixture.events.fail_create(true); + + let context = fixture.anonymous_context(clock(), keys()); + let outcome = DvmJobRequestStrategy.execute(&dvm_event(), &context).await; + + assert!(matches!(outcome, Err(RelayError::Storage(_)))); + assert_eq!(fixture.dvm.jobs().len(), 0); +} + +// --------------------------------------------------------------------------- +// ephemeral — `ephemeral-event-strategy.spec.ts` +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn ephemeral_sends_ok_and_broadcasts_without_storing() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut hub = hub_receiver(&fixture.hub); + let event = event(20_000, Vec::new(), "relayed once"); + + EphemeralStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "")]); + assert_eq!(broadcast(&mut hub), Some(event)); + assert_eq!(fixture.events.events(), Vec::new()); +} + +// --------------------------------------------------------------------------- +// gift_wrap — `gift-wrap-event-strategy.spec.ts` +// --------------------------------------------------------------------------- + +/// The capture's valid wrap: one `p` tag and a NIP-44 v2 payload. +fn gift_wrap_event() -> Event { + event(1059, vec![tag(&["p", &hex(0xaa, 64)])], &nip44_payload()) +} + +#[tokio::test] +async fn gift_wrap_stores_a_valid_wrap() { + let fixture = Fixture::new(settings()); + let context = fixture.anonymous_context(clock(), keys()); + let event = gift_wrap_event(); + + GiftWrapStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!(fixture.events.events(), vec![event]); +} + +#[tokio::test] +async fn gift_wrap_sends_ok_and_broadcasts_when_new() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut hub = hub_receiver(&fixture.hub); + let event = gift_wrap_event(); + + GiftWrapStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "")]); + assert_eq!(broadcast(&mut hub), Some(event)); +} + +#[tokio::test] +async fn gift_wrap_answers_duplicate_without_broadcasting() { + let fixture = Fixture::new(settings()); + let event = gift_wrap_event(); + fixture.seed_event(event.clone()).await; + + let (context, mut sink) = socket(&fixture); + let mut hub = hub_receiver(&fixture.hub); + + GiftWrapStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame(&event, true, "duplicate:")] + ); + assert_eq!(broadcast(&mut hub), None); +} + +#[tokio::test] +async fn gift_wrap_refuses_a_wrap_without_a_p_tag() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut event = gift_wrap_event(); + event.tags = Vec::new(); + + GiftWrapStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "invalid: gift wrap event (kind 1059) must have a p tag identifying the recipient" + )] + ); + assert_eq!(fixture.events.events(), Vec::new()); +} + +#[tokio::test] +async fn gift_wrap_refuses_multiple_p_tags() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut event = gift_wrap_event(); + event.tags = vec![tag(&["p", &hex(0xaa, 64)]), tag(&["p", &hex(0xbb, 64)])]; + + GiftWrapStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "invalid: gift wrap event (kind 1059) must have exactly one p tag" + )] + ); + assert_eq!(fixture.events.events(), Vec::new()); +} + +#[tokio::test] +async fn gift_wrap_accepts_a_p_tag_with_a_relay_hint() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut event = gift_wrap_event(); + event.tags = vec![tag(&["p", &hex(0xaa, 64), "wss://inbox.example.com"])]; + + GiftWrapStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "")]); + assert_eq!(fixture.events.events(), vec![event]); +} + +#[tokio::test] +async fn gift_wrap_refuses_empty_content() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut event = gift_wrap_event(); + event.content = String::new(); + + GiftWrapStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "invalid: gift wrap content must be a valid NIP-44 v2 payload: unsupported encryption version" + )] + ); + assert_eq!(fixture.events.events(), Vec::new()); +} + +#[tokio::test] +async fn gift_wrap_refuses_plain_text_content() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut event = gift_wrap_event(); + event.content = "this is not encrypted".to_owned(); + + GiftWrapStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "invalid: gift wrap content must be a valid NIP-44 v2 payload: invalid payload size" + )] + ); + assert_eq!(fixture.events.events(), Vec::new()); +} + +#[tokio::test] +async fn gift_wrap_refuses_a_hash_prefixed_content() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut event = gift_wrap_event(); + event.content = "#future-version-payload".to_owned(); + + GiftWrapStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "invalid: gift wrap content must be a valid NIP-44 v2 payload: unsupported encryption version" + )] + ); + assert_eq!(fixture.events.events(), Vec::new()); +} + +// --------------------------------------------------------------------------- +// group — `group-event-strategy.spec.ts` +// --------------------------------------------------------------------------- + +fn group_event() -> Event { + event( + 445, + vec![tag(&["h", &hex(0xaa, 64)])], + "base64encodedencryptedcontent", + ) +} + +#[tokio::test] +async fn group_stores_a_valid_group_event() { + let fixture = Fixture::new(settings()); + let context = fixture.anonymous_context(clock(), keys()); + let event = group_event(); + + GroupStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!(fixture.events.events(), vec![event]); +} + +#[tokio::test] +async fn group_sends_ok_and_broadcasts_when_new() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut hub = hub_receiver(&fixture.hub); + let event = group_event(); + + GroupStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "")]); + assert_eq!(broadcast(&mut hub), Some(event)); +} + +#[tokio::test] +async fn group_answers_duplicate_without_broadcasting() { + let fixture = Fixture::new(settings()); + let event = group_event(); + fixture.seed_event(event.clone()).await; + + let (context, mut sink) = socket(&fixture); + let mut hub = hub_receiver(&fixture.hub); + + GroupStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame(&event, true, "duplicate:")] + ); + assert_eq!(broadcast(&mut hub), None); +} + +#[tokio::test] +async fn group_accepts_an_expiration_tag_alongside_the_h_tag() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut event = group_event(); + event.tags = vec![ + tag(&["h", &hex(0xaa, 64)]), + tag(&["expiration", "9999999999"]), + ]; + + GroupStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "")]); + assert_eq!(fixture.events.events(), vec![event]); +} + +#[tokio::test] +async fn group_refuses_a_missing_h_tag() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut event = group_event(); + event.tags = Vec::new(); + + GroupStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "invalid: group event (kind 445) must have an h tag identifying the group" + )] + ); + assert_eq!(fixture.events.events(), Vec::new()); +} + +#[tokio::test] +async fn group_refuses_a_non_h_tag() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut event = group_event(); + event.tags = vec![tag(&["p", &hex(0xaa, 64)])]; + + GroupStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "invalid: group event (kind 445) must have an h tag identifying the group" + )] + ); + assert_eq!(fixture.events.events(), Vec::new()); +} + +#[tokio::test] +async fn group_refuses_two_h_tags() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut event = group_event(); + event.tags = vec![tag(&["h", &hex(0xaa, 64)]), tag(&["h", &hex(0xbb, 64)])]; + + GroupStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "invalid: group event (kind 445) must have exactly one h tag" + )] + ); + assert_eq!(fixture.events.events(), Vec::new()); +} + +#[tokio::test] +async fn group_refuses_a_short_group_id() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut event = group_event(); + event.tags = vec![tag(&["h", &"a".repeat(63)])]; + + GroupStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "invalid: group event (kind 445) h tag must contain a valid 64-character lowercase hex group id" + )] + ); + assert_eq!(fixture.events.events(), Vec::new()); +} + +#[tokio::test] +async fn group_refuses_a_long_group_id() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut event = group_event(); + event.tags = vec![tag(&["h", &"a".repeat(65)])]; + + GroupStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "invalid: group event (kind 445) h tag must contain a valid 64-character lowercase hex group id" + )] + ); + assert_eq!(fixture.events.events(), Vec::new()); +} + +#[tokio::test] +async fn group_refuses_uppercase_hex() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut event = group_event(); + event.tags = vec![tag(&["h", &"A".repeat(64)])]; + + GroupStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "invalid: group event (kind 445) h tag must contain a valid 64-character lowercase hex group id" + )] + ); + assert_eq!(fixture.events.events(), Vec::new()); +} + +#[tokio::test] +async fn group_refuses_a_non_hex_group_id() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut event = group_event(); + event.tags = vec![tag(&["h", &"g".repeat(64)])]; + + GroupStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "invalid: group event (kind 445) h tag must contain a valid 64-character lowercase hex group id" + )] + ); + assert_eq!(fixture.events.events(), Vec::new()); +} + +#[tokio::test] +async fn group_accepts_lowercase_hex() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut event = group_event(); + event.tags = vec![tag(&["h", &"0123456789abcdef".repeat(4)])]; + + GroupStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "")]); + assert_eq!(fixture.events.events(), vec![event]); +} + +// --------------------------------------------------------------------------- +// invite_request — `invite-request-event-strategy.spec.ts` +// --------------------------------------------------------------------------- + +fn invite_request_event() -> Event { + event(28_935, vec![tag(&["claim", "forged-code"])], "") +} + +#[tokio::test] +async fn invite_request_refuses_a_client_published_request() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let event = invite_request_event(); + + InviteRequestStrategy + .execute(&event, &context) + .await + .unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "invalid: kind 28935 is issued by the relay, request one with a REQ for kind 28935" + )] + ); +} + +#[tokio::test] +async fn invite_request_never_broadcasts() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut hub = hub_receiver(&fixture.hub); + let event = invite_request_event(); + + InviteRequestStrategy + .execute(&event, &context) + .await + .unwrap(); + + assert_eq!(frames(&mut sink).len(), 1); + assert_eq!(broadcast(&mut hub), None); +} + +// --------------------------------------------------------------------------- +// join_request — `join-request-event-strategy.spec.ts` +// --------------------------------------------------------------------------- + +fn join_request_event() -> Event { + event(28_934, vec![tag(&["claim", "valid-code"])], "") +} + +#[tokio::test] +async fn join_request_refuses_when_nip43_is_disabled() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = authenticated_socket(&fixture, author()); + let event = join_request_event(); + + JoinRequestStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "restricted: NIP-43 is not enabled on this relay" + )] + ); +} + +#[tokio::test] +async fn join_request_refuses_a_stale_created_at() { + let fixture = Fixture::new(nip43_on()); + let (context, mut sink) = authenticated_socket(&fixture, author()); + let mut event = join_request_event(); + event.created_at = NOW_SECS - 3600; + + JoinRequestStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "invalid: created_at is too far from the current time" + )] + ); +} + +#[tokio::test] +async fn join_request_refuses_an_unauthenticated_request() { + let fixture = Fixture::new(nip43_on()); + let (context, mut sink) = socket(&fixture); + let event = join_request_event(); + + JoinRequestStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "auth-required: authentication required (NIP-42)" + )] + ); +} + +#[tokio::test] +async fn join_request_refuses_a_request_without_a_claim_tag() { + let fixture = Fixture::new(nip43_on()); + let (context, mut sink) = authenticated_socket(&fixture, author()); + let mut event = join_request_event(); + event.tags = Vec::new(); + + JoinRequestStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "invalid: join request requires a claim tag" + )] + ); +} + +#[tokio::test] +async fn join_request_answers_duplicate_when_already_admitted() { + let fixture = Fixture::new(nip43_on()); + fixture.users.insert(User { + pubkey: author(), + is_admitted: true, + is_vanished: false, + balance: 0, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }); + + let (context, mut sink) = authenticated_socket(&fixture, author()); + let event = join_request_event(); + + JoinRequestStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + true, + "duplicate: you are already a member of this relay" + )] + ); + assert_eq!(fixture.invites.claims(), Vec::new()); +} + +#[tokio::test] +async fn join_request_refuses_an_unusable_invite_code() { + let fixture = Fixture::new(nip43_on()); + let (context, mut sink) = authenticated_socket(&fixture, author()); + let event = join_request_event(); + + JoinRequestStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "restricted: invalid or expired invite code" + )] + ); + assert_eq!( + fixture.invites.claims(), + vec![("valid-code".to_owned(), author())] + ); +} + +#[tokio::test] +async fn the_join_request_admits_and_answers_with_the_welcome() { + // The two observable halves of the four exempt cache titles (R11): the claim is spent, the + // member is admitted, and the frame names the relay. + let fixture = Fixture::new(nip43_on()); + fixture + .invites + .create( + "valid-code", + &CreateInviteCodeOptions { + expires_at: None, + remaining_uses: FieldUpdate::Set(1), + created_by: None, + }, + ) + .await + .unwrap(); + + let (context, mut sink) = authenticated_socket(&fixture, author()); + let event = join_request_event(); + + JoinRequestStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + true, + "info: welcome to wss://relay.example.com!" + )] + ); + + let user = UserRepository::find_by_pubkey(fixture.users.as_ref(), &author()) + .await + .unwrap(); + + assert_eq!(user.map(|user| user.is_admitted), Some(true)); + assert_eq!( + fixture.invites.claims(), + vec![("valid-code".to_owned(), author())] + ); +} + +// --------------------------------------------------------------------------- +// leave_request — `leave-request-event-strategy.spec.ts` +// --------------------------------------------------------------------------- + +fn leave_request_event() -> Event { + event(28_936, vec![tag(&["-"])], "") +} + +fn admitted_user() -> User { + User { + pubkey: author(), + is_admitted: true, + is_vanished: false, + balance: 0, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + } +} + +#[tokio::test] +async fn leave_request_refuses_when_nip43_is_disabled() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = authenticated_socket(&fixture, author()); + let event = leave_request_event(); + + LeaveRequestStrategy + .execute(&event, &context) + .await + .unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "restricted: NIP-43 is not enabled on this relay" + )] + ); +} + +#[tokio::test] +async fn leave_request_refuses_a_request_without_the_protected_tag() { + let fixture = Fixture::new(nip43_on()); + fixture.users.insert(admitted_user()); + + let (context, mut sink) = authenticated_socket(&fixture, author()); + let mut event = leave_request_event(); + event.tags = Vec::new(); + + LeaveRequestStrategy + .execute(&event, &context) + .await + .unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "invalid: leave request requires a NIP-70 \"-\" tag" + )] + ); + + let user = UserRepository::find_by_pubkey(fixture.users.as_ref(), &author()) + .await + .unwrap(); + + assert_eq!(user.map(|user| user.is_admitted), Some(true)); +} + +#[tokio::test] +async fn leave_request_refuses_a_stale_created_at() { + let fixture = Fixture::new(nip43_on()); + fixture.users.insert(admitted_user()); + + let (context, mut sink) = authenticated_socket(&fixture, author()); + let mut event = leave_request_event(); + event.created_at = NOW_SECS - 3600; + + LeaveRequestStrategy + .execute(&event, &context) + .await + .unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "invalid: created_at is too far from the current time" + )] + ); +} + +#[tokio::test] +async fn leave_request_refuses_an_unauthenticated_request() { + let fixture = Fixture::new(nip43_on()); + fixture.users.insert(admitted_user()); + + let (context, mut sink) = socket(&fixture); + let event = leave_request_event(); + + LeaveRequestStrategy + .execute(&event, &context) + .await + .unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "auth-required: authentication required (NIP-42)" + )] + ); +} + +#[tokio::test] +async fn leave_request_answers_ok_when_the_user_is_unknown() { + let fixture = Fixture::new(nip43_on()); + let (context, mut sink) = authenticated_socket(&fixture, author()); + let event = leave_request_event(); + + LeaveRequestStrategy + .execute(&event, &context) + .await + .unwrap(); + + assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "")]); + + let user = UserRepository::find_by_pubkey(fixture.users.as_ref(), &author()) + .await + .unwrap(); + + assert_eq!(user, None); +} + +#[tokio::test] +async fn leave_request_answers_ok_when_the_user_is_not_admitted() { + let fixture = Fixture::new(nip43_on()); + fixture.users.insert(User { + is_admitted: false, + ..admitted_user() + }); + + let (context, mut sink) = authenticated_socket(&fixture, author()); + let event = leave_request_event(); + + LeaveRequestStrategy + .execute(&event, &context) + .await + .unwrap(); + + assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "")]); +} + +#[tokio::test] +async fn the_leave_request_revokes_the_admission() { + // The observable half of the two exempt cache titles (R11). + let fixture = Fixture::new(nip43_on()); + fixture.users.insert(admitted_user()); + + let (context, mut sink) = authenticated_socket(&fixture, author()); + let event = leave_request_event(); + + LeaveRequestStrategy + .execute(&event, &context) + .await + .unwrap(); + + assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "")]); + + let user = UserRepository::find_by_pubkey(fixture.users.as_ref(), &author()) + .await + .unwrap(); + + assert_eq!(user.map(|user| user.is_admitted), Some(false)); +} + +// --------------------------------------------------------------------------- +// parameterized_replaceable — `parameterized-replaceable-event-strategy.spec.ts` +// --------------------------------------------------------------------------- + +fn parameterized_event(tags: Vec>) -> Event { + event(30_000, tags, "") +} + +#[tokio::test] +async fn parameterized_replaceable_uses_an_empty_deduplication_key_without_a_d_tag() { + let fixture = Fixture::new(settings()); + let context = fixture.anonymous_context(clock(), keys()); + let event = parameterized_event(Vec::new()); + + ParameterizedReplaceableStrategy + .execute(&event, &context) + .await + .unwrap(); + + assert_eq!(fixture.events.upserts(), vec![vec![json!("")]]); + assert_eq!(fixture.events.events(), vec![event]); +} + +#[tokio::test] +async fn parameterized_replaceable_uses_the_first_d_tag_value() { + let fixture = Fixture::new(settings()); + let context = fixture.anonymous_context(clock(), keys()); + let event = parameterized_event(vec![tag(&["d", "one"])]); + + ParameterizedReplaceableStrategy + .execute(&event, &context) + .await + .unwrap(); + + assert_eq!(fixture.events.upserts(), vec![vec![json!("one")]]); +} + +#[tokio::test] +async fn parameterized_replaceable_uses_only_the_first_d_tag_element() { + let fixture = Fixture::new(settings()); + let context = fixture.anonymous_context(clock(), keys()); + let event = parameterized_event(vec![tag(&["d", "one", "two"])]); + + ParameterizedReplaceableStrategy + .execute(&event, &context) + .await + .unwrap(); + + assert_eq!(fixture.events.upserts(), vec![vec![json!("one")]]); +} + +#[tokio::test] +async fn parameterized_replaceable_broadcasts_when_created() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut hub = hub_receiver(&fixture.hub); + let event = parameterized_event(vec![tag(&["d", "one"])]); + + ParameterizedReplaceableStrategy + .execute(&event, &context) + .await + .unwrap(); + + assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "")]); + assert_eq!(broadcast(&mut hub), Some(event)); +} + +#[tokio::test] +async fn parameterized_replaceable_does_not_broadcast_a_duplicate() { + let fixture = Fixture::new(settings()); + let event = parameterized_event(vec![tag(&["d", "one"])]); + + // The conflict target is the deduplication key, so the stored row has to carry the key the + // arm writes. + EventRepository::upsert( + fixture.events.as_ref(), + &EventRecord::from_event(&event).with_deduplication(vec![json!("one")]), + ) + .await + .unwrap(); + + let (context, mut sink) = socket(&fixture); + let mut hub = hub_receiver(&fixture.hub); + + ParameterizedReplaceableStrategy + .execute(&event, &context) + .await + .unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame(&event, true, "duplicate:")] + ); + assert_eq!(broadcast(&mut hub), None); + assert_eq!( + fixture.events.upserts(), + vec![vec![json!("one")], vec![json!("one")]] + ); +} + +#[tokio::test] +async fn parameterized_replaceable_returns_an_error_when_the_upsert_fails() { + let fixture = Fixture::new(settings()); + fixture.events.fail_upsert(UpsertFailure::Unavailable); + + let (context, mut sink) = socket(&fixture); + let outcome = ParameterizedReplaceableStrategy + .execute(¶meterized_event(vec![tag(&["d", "one"])]), &context) + .await; + + assert!(matches!(outcome, Err(RelayError::Storage(_)))); + assert_eq!(frames(&mut sink), Vec::::new()); +} + +// --------------------------------------------------------------------------- +// replaceable — `replaceable-event-strategy.spec.ts` +// --------------------------------------------------------------------------- + +fn replaceable_event() -> Event { + event(0, Vec::new(), "{\"name\":\"relay\"}") +} + +#[tokio::test] +async fn replaceable_upserts_the_event() { + let fixture = Fixture::new(settings()); + let context = fixture.anonymous_context(clock(), keys()); + let event = replaceable_event(); + + ReplaceableStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + fixture.events.upserts(), + vec![vec![json!(author().to_string()), json!(0)]] + ); + assert_eq!(fixture.events.events(), vec![event]); +} + +#[tokio::test] +async fn replaceable_broadcasts_when_new() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut hub = hub_receiver(&fixture.hub); + let event = replaceable_event(); + + ReplaceableStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "")]); + assert_eq!(broadcast(&mut hub), Some(event)); +} + +#[tokio::test] +async fn replaceable_does_not_broadcast_when_the_stored_row_wins() { + let fixture = Fixture::new(settings()); + let event = replaceable_event(); + fixture.seed_event(event.clone()).await; + + let (context, mut sink) = socket(&fixture); + let mut hub = hub_receiver(&fixture.hub); + + ReplaceableStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame(&event, true, "duplicate:")] + ); + assert_eq!(broadcast(&mut hub), None); +} + +#[tokio::test] +async fn replaceable_returns_an_error_on_an_unexpected_upsert_failure() { + // The capture answers `error: `; the port's `StorageError` forbids forwarding + // backend text, so that failure leaves `execute` (ADR 0006, R2). The one failure that *is* a + // decision — the event id already exists — is still a refusal, and the capture has no case + // for it here, so it is asserted beside the other. + let fixture = Fixture::new(settings()); + fixture.events.fail_upsert(UpsertFailure::Unavailable); + + let (context, mut sink) = socket(&fixture); + let event = replaceable_event(); + let outcome = ReplaceableStrategy.execute(&event, &context).await; + + assert!(matches!(outcome, Err(RelayError::Storage(_)))); + assert_eq!(frames(&mut sink), Vec::::new()); + + fixture.events.fail_upsert(UpsertFailure::DuplicateEvent); + + ReplaceableStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame(&event, false, "rejected: event already exists")] + ); +} + +// --------------------------------------------------------------------------- +// report — `report-event-strategy.spec.ts` +// --------------------------------------------------------------------------- + +/// The capture's kind-1984 fixture: the author reports `reported` for spam. +fn report_event() -> Event { + event(1984, vec![tag(&["p", &hex(0x33, 64), "spam"])], "") +} + +/// The row the capture's fixture asserts for [`report_event`], at `weight`. +fn expected_report(weight: f32, actionable: bool) -> NewReport { + NewReport { + event_id: EventId::from_byte_array([0x11; 32]), + reporter_pubkey: author(), + reported_pubkey: Some(hex(0x33, 64).parse().unwrap()), + reported_event_id: None, + report_type: ReportType::Spam, + weight, + actionable, + } +} + +/// An event whose author is the reported pubkey, for the hidden-content assertion. +fn reported_authors_event() -> Event { + Event { + id: EventId::from_byte_array([0x77; 32]), + pubkey: hex(0x33, 64).parse().unwrap(), + ..event(1, Vec::new(), "reported") + } +} + +#[tokio::test] +async fn report_creates_the_event() { + let fixture = Fixture::new(nip56_on(Vec::new(), false)); + let context = fixture.anonymous_context(clock(), keys()); + let event = report_event(); + + ReportStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!(fixture.events.events(), vec![event]); +} + +#[tokio::test] +async fn report_broadcasts_the_event_when_new() { + let fixture = Fixture::new(nip56_on(Vec::new(), false)); + let (context, mut sink) = socket(&fixture); + let mut hub = hub_receiver(&fixture.hub); + let event = report_event(); + + ReportStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "")]); + assert_eq!(broadcast(&mut hub), Some(event)); +} + +#[tokio::test] +async fn report_does_not_broadcast_or_record_a_duplicate() { + let fixture = Fixture::new(nip56_on(Vec::new(), false)); + let event = report_event(); + fixture.seed_event(event.clone()).await; + + let (context, mut sink) = socket(&fixture); + let mut hub = hub_receiver(&fixture.hub); + + ReportStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame(&event, true, "duplicate:")] + ); + assert_eq!(broadcast(&mut hub), None); + assert_eq!(fixture.reports.reports(), Vec::new()); +} + +#[tokio::test] +async fn report_records_full_weight_for_a_direct_follow() { + let fixture = Fixture::new(nip56_on(Vec::new(), false)); + fixture.wot.set_distance(Some(1)); + + let context = fixture.anonymous_context(clock(), keys()); + + ReportStrategy + .execute(&report_event(), &context) + .await + .unwrap(); + + assert_eq!(fixture.reports.reports(), vec![expected_report(1.0, false)]); + assert_eq!(fixture.wot.distance_queries(), vec![author()]); +} + +#[tokio::test] +async fn report_records_zero_weight_outside_the_graph() { + let fixture = Fixture::new(nip56_on(Vec::new(), false)); + let context = fixture.anonymous_context(clock(), keys()); + + ReportStrategy + .execute(&report_event(), &context) + .await + .unwrap(); + + assert_eq!(fixture.reports.reports(), vec![expected_report(0.0, false)]); + assert_eq!(fixture.wot.distance_queries(), vec![author()]); +} + +#[tokio::test] +async fn report_is_stored_unweighted_when_the_graph_is_unavailable() { + let fixture = Fixture::new(nip56_on(Vec::new(), false)); + let (mut context, mut sink) = socket(&fixture); + context.wot = Arc::new(UnavailableWotGraph); + + // A daemon with no graph wired is the capture's disabled-WoT branch: no distance is known, + // so the row is stored unweighted rather than the write being skipped. + let event = report_event(); + + ReportStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!(fixture.reports.reports(), vec![expected_report(0.0, false)]); + assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "")]); +} + +#[tokio::test] +async fn report_records_a_max_weight_actionable_row_for_a_moderator() { + let fixture = Fixture::new(nip56_on(vec![author()], false)); + let context = fixture.anonymous_context(clock(), keys()); + + ReportStrategy + .execute(&report_event(), &context) + .await + .unwrap(); + + assert_eq!(fixture.reports.reports(), vec![expected_report(1.0, true)]); +} + +#[tokio::test] +async fn report_marks_the_target_hidden_when_hiding_is_enabled() { + let fixture = Fixture::new(nip56_on(vec![author()], true)); + let context = fixture.anonymous_context(clock(), keys()); + + ReportStrategy + .execute(&report_event(), &context) + .await + .unwrap(); + + assert!(fixture.hidden.is_hidden(&reported_authors_event())); +} + +#[tokio::test] +async fn report_does_not_mark_the_target_when_hiding_is_disabled() { + let fixture = Fixture::new(nip56_on(vec![author()], false)); + let context = fixture.anonymous_context(clock(), keys()); + + ReportStrategy + .execute(&report_event(), &context) + .await + .unwrap(); + + assert_eq!(fixture.reports.reports().len(), 1); + assert!(!fixture.hidden.is_hidden(&reported_authors_event())); +} + +#[tokio::test] +async fn report_does_not_mark_the_target_for_a_non_moderator() { + let fixture = Fixture::new(nip56_on(Vec::new(), true)); + fixture.wot.set_distance(Some(1)); + + let context = fixture.anonymous_context(clock(), keys()); + + ReportStrategy + .execute(&report_event(), &context) + .await + .unwrap(); + + assert_eq!(fixture.reports.reports().len(), 1); + assert!(!fixture.hidden.is_hidden(&reported_authors_event())); +} + +#[tokio::test] +async fn report_does_not_consult_the_graph_for_a_moderator() { + let fixture = Fixture::new(nip56_on(vec![author()], false)); + let context = fixture.anonymous_context(clock(), keys()); + + ReportStrategy + .execute(&report_event(), &context) + .await + .unwrap(); + + assert_eq!(fixture.wot.distance_queries(), Vec::new()); +} + +#[tokio::test] +async fn report_records_nothing_without_a_valid_target() { + let fixture = Fixture::new(nip56_on(Vec::new(), false)); + let context = fixture.anonymous_context(clock(), keys()); + let mut event = report_event(); + event.tags = Vec::new(); + + ReportStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!(fixture.reports.reports(), Vec::new()); +} + +#[tokio::test] +async fn report_records_nothing_for_a_moderator_without_a_valid_target() { + let fixture = Fixture::new(nip56_on(vec![author()], false)); + let context = fixture.anonymous_context(clock(), keys()); + let mut event = report_event(); + event.tags = Vec::new(); + + ReportStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!(fixture.reports.reports(), Vec::new()); +} + +#[tokio::test] +async fn report_records_one_row_per_target_in_one_batch() { + let fixture = Fixture::new(nip56_on(Vec::new(), false)); + fixture.wot.set_distance(Some(1)); + + let context = fixture.anonymous_context(clock(), keys()); + let mut event = report_event(); + event.tags = vec![ + tag(&["p", &hex(0x33, 64), "impersonation"]), + tag(&["e", &other_event_id().to_string(), "nudity"]), + ]; + + ReportStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!(fixture.reports.report_writes().len(), 1); + assert_eq!( + fixture.reports.reports(), + vec![ + NewReport { + reported_pubkey: None, + reported_event_id: Some(other_event_id()), + report_type: ReportType::Nudity, + ..expected_report(1.0, false) + }, + NewReport { + reported_event_id: None, + report_type: ReportType::Impersonation, + ..expected_report(1.0, false) + }, + ] + ); +} + +#[tokio::test] +async fn report_stores_the_event_without_scoring_when_nip56_is_off() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut hub = hub_receiver(&fixture.hub); + let event = report_event(); + + ReportStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!(fixture.events.events(), vec![event.clone()]); + assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "")]); + assert_eq!(broadcast(&mut hub), Some(event)); + assert_eq!(fixture.reports.reports(), Vec::new()); + assert_eq!(fixture.wot.distance_queries(), Vec::new()); +} + +#[tokio::test] +async fn report_does_not_reject_the_event_when_recording_fails() { + let fixture = Fixture::new(nip56_on(Vec::new(), false)); + fixture.reports.fail_writes(); + + let (context, mut sink) = socket(&fixture); + let event = report_event(); + + ReportStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "")]); + assert_eq!(fixture.reports.reports(), Vec::new()); +} + +#[tokio::test] +async fn report_returns_an_error_when_the_create_fails() { + let fixture = Fixture::new(nip56_on(Vec::new(), false)); + fixture.events.fail_create(true); + + let context = fixture.anonymous_context(clock(), keys()); + let outcome = ReportStrategy.execute(&report_event(), &context).await; + + assert!(matches!(outcome, Err(RelayError::Storage(_)))); + assert_eq!(fixture.reports.reports(), Vec::new()); +} + +// --------------------------------------------------------------------------- +// timestamp — `timestamp-event-strategy.spec.ts` +// --------------------------------------------------------------------------- + +/// The 31-byte `.ots` magic header (`src/utils/nip03.ts:27-30`). +const OTS_MAGIC: [u8; 31] = [ + 0x00, 0x4f, 0x70, 0x65, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, 0x00, + 0x00, 0x50, 0x72, 0x6f, 0x6f, 0x66, 0x00, 0xbf, 0x89, 0xe2, 0xe8, 0x84, 0xe8, 0x92, 0x94, +]; + +/// The Bitcoin block-header attestation tag. +const OTS_BITCOIN_TAG: [u8; 8] = [0x05, 0x88, 0x96, 0x0d, 0x73, 0xd7, 0x19, 0x01]; + +/// The sha256 file-hash op. +const OTS_OP_SHA256: u8 = 0x08; + +fn write_varuint(mut value: u64) -> Vec { + let mut out = Vec::new(); + + loop { + let mut byte = (value & 0x7f) as u8; + value >>= 7; + + if value != 0 { + byte |= 0x80; + } + + out.push(byte); + + if value == 0 { + return out; + } + } +} + +fn bitcoin_attestation(block_height: u64) -> Vec { + let payload = write_varuint(block_height); + let mut bytes = vec![0x00]; + bytes.extend(OTS_BITCOIN_TAG); + bytes.extend(write_varuint(payload.len() as u64)); + bytes.extend(payload); + + bytes +} + +/// A valid proof whose file digest is `digest` (`buildValidOtsForDigest`, +/// `timestamp-event-strategy.spec.ts:56-60`). +fn valid_ots_for(digest: &EventId) -> String { + let mut bytes = OTS_MAGIC.to_vec(); + bytes.push(1); + bytes.push(OTS_OP_SHA256); + bytes.extend(digest.as_bytes()); + bytes.extend(bitcoin_attestation(810_391)); + + STANDARD.encode(bytes) +} + +/// The attested event id: a 32-byte id the helper can build a proof for. +fn attested_id() -> EventId { + other_event_id() +} + +fn timestamp_event() -> Event { + event( + 1040, + vec![ + tag(&["e", &attested_id().to_string(), "wss://relay.example.com"]), + tag(&["k", "1"]), + ], + &valid_ots_for(&attested_id()), + ) +} + +#[tokio::test] +async fn timestamp_stores_and_broadcasts_a_valid_proof() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut hub = hub_receiver(&fixture.hub); + let event = timestamp_event(); + + TimestampStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!(fixture.events.events(), vec![event.clone()]); + assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "")]); + assert_eq!(broadcast(&mut hub), Some(event)); +} + +#[tokio::test] +async fn timestamp_answers_duplicate_without_broadcasting() { + let fixture = Fixture::new(settings()); + let event = timestamp_event(); + fixture.seed_event(event.clone()).await; + + let (context, mut sink) = socket(&fixture); + let mut hub = hub_receiver(&fixture.hub); + + TimestampStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame(&event, true, "duplicate:")] + ); + assert_eq!(broadcast(&mut hub), None); +} + +#[tokio::test] +async fn timestamp_accepts_an_event_without_a_k_tag() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut event = timestamp_event(); + event.tags = vec![tag(&["e", &attested_id().to_string()])]; + + TimestampStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!(fixture.events.events(), vec![event.clone()]); + assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "")]); +} + +#[tokio::test] +async fn timestamp_refuses_a_missing_e_tag() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut event = timestamp_event(); + event.tags = Vec::new(); + + TimestampStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "invalid: opentimestamps event (kind 1040) must have an e tag referencing the attested event" + )] + ); + assert_eq!(fixture.events.events(), Vec::new()); +} + +#[tokio::test] +async fn timestamp_refuses_multiple_e_tags() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut event = timestamp_event(); + event.tags = vec![ + tag(&["e", &attested_id().to_string()]), + tag(&["e", &hex(0xbb, 64)]), + ]; + + TimestampStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "invalid: opentimestamps event (kind 1040) must reference exactly one event" + )] + ); + assert_eq!(fixture.events.events(), Vec::new()); +} + +#[tokio::test] +async fn timestamp_refuses_a_malformed_e_tag() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut event = timestamp_event(); + event.tags = vec![tag(&["e", "not-a-hex-id"])]; + + TimestampStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "invalid: opentimestamps e tag must contain a 32-byte lowercase hex event id" + )] + ); + assert_eq!(fixture.events.events(), Vec::new()); +} + +#[tokio::test] +async fn timestamp_refuses_uppercase_hex_in_the_e_tag() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut event = timestamp_event(); + event.tags = vec![tag(&["e", &attested_id().to_string().to_uppercase()])]; + + TimestampStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "invalid: opentimestamps e tag must contain a 32-byte lowercase hex event id" + )] + ); + assert_eq!(fixture.events.events(), Vec::new()); +} + +#[tokio::test] +async fn timestamp_refuses_a_non_integer_k_tag() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut event = timestamp_event(); + event.tags = vec![ + tag(&["e", &attested_id().to_string()]), + tag(&["k", "banana"]), + ]; + + TimestampStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "invalid: opentimestamps k tag must be a non-negative integer kind" + )] + ); + assert_eq!(fixture.events.events(), Vec::new()); +} + +#[tokio::test] +async fn timestamp_refuses_a_negative_k_tag() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut event = timestamp_event(); + event.tags = vec![tag(&["e", &attested_id().to_string()]), tag(&["k", "-1"])]; + + TimestampStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "invalid: opentimestamps k tag must be a non-negative integer kind" + )] + ); + assert_eq!(fixture.events.events(), Vec::new()); +} + +#[tokio::test] +async fn timestamp_refuses_multiple_k_tags() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut event = timestamp_event(); + event.tags = vec![ + tag(&["e", &attested_id().to_string()]), + tag(&["k", "1"]), + tag(&["k", "2"]), + ]; + + TimestampStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "invalid: opentimestamps event (kind 1040) must have at most one k tag" + )] + ); + assert_eq!(fixture.events.events(), Vec::new()); +} + +#[tokio::test] +async fn timestamp_refuses_a_mismatched_ots_digest() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut event = timestamp_event(); + event.content = valid_ots_for(&EventId::from_byte_array([0xdd; 32])); + + TimestampStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "invalid: ots proof digest does not match the referenced event id" + )] + ); + assert_eq!(fixture.events.events(), Vec::new()); +} + +#[tokio::test] +async fn timestamp_refuses_content_that_is_not_an_ots_proof() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut event = timestamp_event(); + event.content = + STANDARD.encode(b"this is long enough to reach the header check, but it is not a proof"); + + TimestampStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "invalid: invalid ots proof: invalid ots magic header" + )] + ); + assert_eq!(fixture.events.events(), Vec::new()); +} + +#[tokio::test] +async fn timestamp_refuses_empty_content() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut event = timestamp_event(); + event.content = String::new(); + + TimestampStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame( + &event, + false, + "invalid: content is empty; expected base64-encoded ots file" + )] + ); + assert_eq!(fixture.events.events(), Vec::new()); +} + +// --------------------------------------------------------------------------- +// vanish — `vanish-event-strategy.spec.ts` +// --------------------------------------------------------------------------- + +fn vanish_event() -> Event { + event(62, vec![tag(&["r", "relay_url"])], "") +} + +#[tokio::test] +async fn vanish_soft_deletes_the_other_events_and_stores_the_request() { + let fixture = Fixture::new(settings()); + + for (id, content) in [(0x01u8, "first"), (0x02u8, "second")] { + fixture + .seed_event(Event { + id: EventId::from_byte_array([id; 32]), + ..event(1, Vec::new(), content) + }) + .await; + } + + let (context, mut sink) = socket(&fixture); + let event = vanish_event(); + + VanishStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + fixture.events.delete_except_kinds_calls(), + vec![(author(), vec![Kind::REQUEST_TO_VANISH])] + ); + assert_eq!(fixture.events.live_events(), vec![event.clone()]); + assert_eq!(fixture.users.vanish_calls(), vec![(author(), true)]); + assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "")]); +} + +#[tokio::test] +async fn vanish_never_broadcasts() { + let fixture = Fixture::new(settings()); + let (context, mut sink) = socket(&fixture); + let mut hub = hub_receiver(&fixture.hub); + let event = vanish_event(); + + VanishStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!(frames(&mut sink).len(), 1); + assert_eq!(broadcast(&mut hub), None); +} + +#[tokio::test] +async fn vanish_answers_duplicate_when_the_request_exists() { + let fixture = Fixture::new(settings()); + let event = vanish_event(); + fixture.seed_event(event.clone()).await; + + let (context, mut sink) = socket(&fixture); + + VanishStrategy.execute(&event, &context).await.unwrap(); + + assert_eq!( + frames(&mut sink), + vec![ok_frame(&event, true, "duplicate:")] + ); +} diff --git a/crates/nostream-relay/tests/ts_case_parity.rs b/crates/nostream-relay/tests/ts_case_parity.rs index 8bfbd2be4..ed2b4ad01 100644 --- a/crates/nostream-relay/tests/ts_case_parity.rs +++ b/crates/nostream-relay/tests/ts_case_parity.rs @@ -1,4 +1,4 @@ -//! R30's checked census: every unit case of the 22 in-scope TypeScript spec files must name the +//! R30's checked census: every unit case of the 36 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 @@ -21,10 +21,66 @@ const CENSUS: &[(&str, usize)] = &[ ("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/contact-list-event-strategy.spec.ts", + 7, + ), ( "test/unit/handlers/event-strategies/default-event-strategy.spec.ts", 4, ), + ( + "test/unit/handlers/event-strategies/delete-event-strategy.spec.ts", + 6, + ), + ( + "test/unit/handlers/event-strategies/dvm-job-request-event-strategy.spec.ts", + 6, + ), + ( + "test/unit/handlers/event-strategies/ephemeral-event-strategy.spec.ts", + 1, + ), + ( + "test/unit/handlers/event-strategies/gift-wrap-event-strategy.spec.ts", + 9, + ), + ( + "test/unit/handlers/event-strategies/group-event-strategy.spec.ts", + 12, + ), + ( + "test/unit/handlers/event-strategies/invite-request-event-strategy.spec.ts", + 2, + ), + ( + "test/unit/handlers/event-strategies/join-request-event-strategy.spec.ts", + 8, + ), + ( + "test/unit/handlers/event-strategies/leave-request-event-strategy.spec.ts", + 8, + ), + ( + "test/unit/handlers/event-strategies/parameterized-replaceable-event-strategy.spec.ts", + 6, + ), + ( + "test/unit/handlers/event-strategies/replaceable-event-strategy.spec.ts", + 4, + ), + ( + "test/unit/handlers/event-strategies/report-event-strategy.spec.ts", + 16, + ), + ( + "test/unit/handlers/event-strategies/timestamp-event-strategy.spec.ts", + 13, + ), + ( + "test/unit/handlers/event-strategies/vanish-event-strategy.spec.ts", + 3, + ), ("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), @@ -154,7 +210,7 @@ fn the_census_counts_are_the_tables() { table.keys().len(), "the census covers a different file set than the table: {counted:?}" ); - assert_eq!(total, 557, "the census is 557 cases"); + assert_eq!(total, 658, "the census is 658 cases"); } #[test] @@ -301,7 +357,7 @@ fn the_map_records_the_census_it_was_built_from() { assert_eq!(map["capture"].as_str(), census["capture"].as_str()); assert_eq!( map["cases"].as_u64(), - Some(557), + Some(658), "the map covers the whole census" ); } diff --git a/crates/nostream-relay/tests/wire_parity.rs b/crates/nostream-relay/tests/wire_parity.rs index 6b37f4e31..ed9a699b8 100644 --- a/crates/nostream-relay/tests/wire_parity.rs +++ b/crates/nostream-relay/tests/wire_parity.rs @@ -251,6 +251,7 @@ impl Server { let relay = Relay::new( session.settings.clone(), Arc::clone(&fixture.stores), + fixture.wot_graph(), Arc::clone(&clock) as Arc, keys, random, @@ -489,10 +490,19 @@ async fn replay(session: &Session, random: Arc) -> BTreeMap StorageError { + match &error { + sqlx::Error::Database(database) + if database.code().as_deref() == Some(EVENT_ID_UNIQUE_VIOLATION_CODE) + && database.constraint() == Some(EVENT_ID_UNIQUE_CONSTRAINT) => + { + StorageError::DuplicateEvent + } + _ => StorageError::Database(error), + } +} + /// `upsert_many`: one multi-row replaceable insert. The capture's explicit merge list omits /// `remote_address`, and so does this one. pub(crate) async fn upsert_many<'e, E>( diff --git a/crates/nostream-storage/src/records.rs b/crates/nostream-storage/src/records.rs index c915dc6b6..c9baf27de 100644 --- a/crates/nostream-storage/src/records.rs +++ b/crates/nostream-storage/src/records.rs @@ -178,6 +178,22 @@ impl DvmJobStatus { } } +/// The report arm parses NIP-56's seven types, so the mapping is total by construction and +/// never needs a default. +impl From for ReportType { + fn from(report_type: nostream_core::nip56::ReportType) -> Self { + match report_type { + nostream_core::nip56::ReportType::Nudity => Self::Nudity, + nostream_core::nip56::ReportType::Malware => Self::Malware, + nostream_core::nip56::ReportType::Profanity => Self::Profanity, + nostream_core::nip56::ReportType::Illegal => Self::Illegal, + nostream_core::nip56::ReportType::Spam => Self::Spam, + nostream_core::nip56::ReportType::Impersonation => Self::Impersonation, + nostream_core::nip56::ReportType::Other => Self::Other, + } + } +} + /// The integer columns are `integer`; a value outside `i32` cannot come from them, and a /// negative one cannot name a kind or a creation time. pub fn kind_from_i32(value: i32) -> Result { @@ -275,6 +291,26 @@ mod tests { ); } + #[test] + fn a_parsed_report_type_maps_to_the_column_spelling() { + use nostream_core::nip56::ReportType as Parsed; + + let cases = [ + (Parsed::Nudity, "nudity"), + (Parsed::Malware, "malware"), + (Parsed::Profanity, "profanity"), + (Parsed::Illegal, "illegal"), + (Parsed::Spam, "spam"), + (Parsed::Impersonation, "impersonation"), + (Parsed::Other, "other"), + ]; + + for (parsed, spelling) in cases { + assert_eq!(ReportType::from(parsed).as_str(), spelling); + assert_eq!(parsed.as_str(), spelling); + } + } + #[test] fn every_column_enum_round_trips_its_spelling() { let cases: Vec<(&str, &str)> = vec![ diff --git a/crates/nostreamd/src/main.rs b/crates/nostreamd/src/main.rs index b3a9b2bbd..a5937dd18 100644 --- a/crates/nostreamd/src/main.rs +++ b/crates/nostreamd/src/main.rs @@ -111,6 +111,8 @@ async fn serve( let relay = Relay::new( settings, std::sync::Arc::new(unavailable::stores()), + // No web-of-trust graph is wired yet (#866): the capture's disabled-graph branch. + std::sync::Arc::new(unavailable::UnavailableWotGraph), std::sync::Arc::new(SystemClock), RelayKeySource::from_env(), std::sync::Arc::new(nostream_core::random::SystemRandom), diff --git a/crates/nostreamd/tests/acceptor.rs b/crates/nostreamd/tests/acceptor.rs index f43eed6cc..129c46d30 100644 --- a/crates/nostreamd/tests/acceptor.rs +++ b/crates/nostreamd/tests/acceptor.rs @@ -27,6 +27,7 @@ async fn the_acceptor_binds_an_ephemeral_port_and_drains_on_shutdown() { let relay = Relay::new( (*fixture.settings).clone(), Arc::clone(&fixture.stores), + fixture.wot_graph(), Arc::new(TestClock::new(1_700_000_000_000)), RelayKeySource::from_values(None, None), Arc::new(nostream_core::random::SystemRandom), diff --git a/crates/nostreamd/tests/wire.rs b/crates/nostreamd/tests/wire.rs index f2f080919..222783f26 100644 --- a/crates/nostreamd/tests/wire.rs +++ b/crates/nostreamd/tests/wire.rs @@ -30,6 +30,7 @@ async fn start() -> (u16, ShutdownState) { let relay = Relay::new( (*fixture.settings).clone(), Arc::clone(&fixture.stores), + fixture.wot_graph(), Arc::new(TestClock::new(1_700_000_000_000)), RelayKeySource::from_values(None, None), Arc::new(nostream_core::random::SystemRandom), diff --git a/docs/rewrite/decisions/0004-wire-transport.md b/docs/rewrite/decisions/0004-wire-transport.md index 81ef86dcd..835d7bd23 100644 --- a/docs/rewrite/decisions/0004-wire-transport.md +++ b/docs/rewrite/decisions/0004-wire-transport.md @@ -10,7 +10,7 @@ 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 +port is faithful — a checked census of the capture's 658 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 @@ -264,11 +264,11 @@ handle's owner is the caller that started it. ## The census is a checked artifact -`tests/fixtures/ts-cases.json` is the 557 case titles of the 22 in-scope spec files, +`tests/fixtures/ts-cases.json` is the 658 case titles of the 36 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 +`#[ignore]`d, or a per-file count disagrees with the spec's table. 46 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). diff --git a/docs/rewrite/decisions/0006-event-strategies.md b/docs/rewrite/decisions/0006-event-strategies.md new file mode 100644 index 000000000..f84d6526d --- /dev/null +++ b/docs/rewrite/decisions/0006-event-strategies.md @@ -0,0 +1,140 @@ +# ADR 0006: The fourteen event strategies, their two seams, and the census that carries them (job 011) + +- **Status:** accepted (job 011, Phase 2) +- **Deciders:** the rewrite team, on the evidence in job 011's recon and spec +- **Date:** 2026-10-24 + +## Context + +Fourteen of the relay's fifteen `EventStrategy` arms answered +`RelayError::NotImplemented`, so a kind outside `default`'s reach was refused on the wire; +now each answers the frame its TypeScript counterpart emits +(`src/handlers/event-strategies/*-event-strategy.ts`). Filling a body exposed four +decisions the code cannot show on its face: two of the arms call a service the relay does +not own, one arm cannot be honest without a binary-format parser, one storage outcome the +capture catches by string has no Rust variant, and one hex check disagrees with the other +four on purpose. + +This ADR records those, and the census total that moved with them. Each heading is +greppable. + +## The web-of-trust graph is the relay's own trait + +`contact_list` replaces a pubkey's follows (`contact-list-event-strategy.ts:36`) and +`report` asks a reporter's distance (`report-event-strategy.ts:53`). The graph is a +service (`nostream-services`' `wot_graph_service`, #866), and `nostream-relay` takes no +dependency on that crate: `src/wot.rs:25` declares `WotGraph` beside `limits::Limiter`, +which is the file's existing precedent for a service the relay needs and does not own. The +instance follows the same precedent: the composition root picks it, `Relay::new` takes it, +and `HandlerContext` carries it beside `limits` — not in `Stores`, which holds storage +traits only. + +The shapes rejected: + +- **Depend on `nostream-services`.** The service is a Phase 1 stub, so the arms would be + written against a signature that has not been designed, and every relay test would link a + crate the wire does not otherwise touch. +- **Read the graph out of storage.** The capture's graph is in-memory state behind its + service; a repository trait here would fix an on-disk shape #866 has not chosen. +- **Inline the whole graph in the relay.** That is #866's work, not this job's. + +`UnavailableWotGraph` (`src/unavailable.rs:41-67`) is the capture's *disabled* graph, not a +failing one: a follow-list update touches nothing and `get_distance` answers `None`, which +`report` stores as an unweighted row — a report from a relay with no graph is still a +report. `Err` is reserved for a wired implementation's genuine failure (a rebuild that +could not complete), and both arms log that and carry on, as the capture's `try`/`catch` +does. Saying `Err` for "no graph" would have dropped every report row on a daemon without +#866 while the event itself was already stored — unrecoverable, since a resend hits the +duplicate path. The other consequence is recorded where it bites: ADR 0004's adaptive-PoW +section says a missing distance is charged in full. + +## The OpenTimestamps parser lives in core + +The `timestamp` arm's seven structural rules are cheap; its proof is not. A kind-1040 event +whose proof does not commit to the event it names is worse than useless to a client, so the +arm cannot answer `OK true` without parsing the `.ots` bytes, and it cannot answer a +reason string it did not compute — the capture's five `validateOtsProof` refusals are the +text of the client's `OK` frame. + +The parser is `nostream-core::nip03` (`src/nip03.rs`), not a relay module, because the +capture's verifier is `src/utils/nip03.ts` — core's declared surface — and because the +relay's `Cargo.toml` names no binary parser. `parse_ots_file` is structural by +construction: a relay cannot verify an attestation against the chain (`src/nip03.rs:3-6`), +so it validates the magic header, the LEB128 lengths, the tree, and the three +relay-observable requirements (sha256 file hash, digest equal to the attested id, at least +one Bitcoin attestation), and every rejection is the capture's reason verbatim. + +**`OtsSummary::digest` is `Vec`, not `[u8; 32]`** (`src/nip03.rs:108`), which departs +from this job's spec. A sha1 or ripemd160 proof carries a 20-byte digest, and the capture +reads the file hash op's own length before the digest. Refusing a short digest at parse time +would move one of `validateOtsProof`'s five rules into the parser and change the reason the +client sees from `ots proof must use sha256 file hash op (got sha1)` to a parse error; two +census cases pin that reason. The digest is compared as a slice +(`src/nip03.rs:199`), so the sha256 rule stays where the capture puts it. + +## A report's weight is a method on the enum + +The `real` column wants a number (`NewReport::weight: f32`); the port's decision wants a +classification (a moderator's report is actionable whatever the distance, an unreachable +reporter's never is). `ReportWeight::weight` (`src/report_scoring.rs:22`) keeps both: the +enum `score_reporter` returns, and the f32 the row stores — `1.0` for `Trusted`, `0.0` for +`Unweighted`, and `2^-(distance-1)` for `Weighted`, with `distance <= 1` flat at `1.0`. One +representation would have lost the actionable bit; `Trusted` and `Weighted { distance: 1 }` +both weigh `1.0` and only the first marks the target hidden. + +f32 underflows for a very large distance, which the capture's JS does not; the method +returns `0.0` from `hops >= 150` rather than depending on `powi`'s own underflow, and no +census case reaches it. + +## `StorageError::DuplicateEvent`, and no backend text on the wire + +`replaceable` and `contact_list` catch the capture's duplicate insert by matching the text +`duplicate key value violates unique constraint "events_event_id_unique"` +(`replaceable-event-strategy.ts:27`, `contact-list-event-strategy.ts:45`) and answer +`rejected: event already exists`. `StorageError` had no such variant, and the storage +crate's own doc forbids forwarding a driver's message to a caller +(`crates/nostream-storage/src/error.rs:20-21`). + +So the variant is new (`src/error.rs:18`, Display "the event id already exists"), produced +by the Postgres backend from SQLSTATE `23505` *and* the constraint name +(`postgres/events.rs:118`), and by the in-memory double on demand — which is how the two +`rejected:` census cases are tested without a database. Every other storage failure passes +through unchanged and leaves `execute` as an `Err`, answered by the handler as +`error: unable to process event`: a store failure is not a policy string about the +publisher, and the two census titles that describe the capture's +`error: ` are carried by asserting that `Err` (spec Assumption 3). + +## The `/i` asymmetry is reproduced, not fixed + +`src/utils/nip56.ts:16` tests a 64-hex value as `/^[0-9a-f]{64}$/i`, so the report arm +accepts uppercase hex. The four validators that refuse uppercase are lowercase-only: +`delete-event-strategy.ts:22`, `gift-wrap-event-strategy.ts:49`, +`group-event-strategy.ts:50` and `timestamp-event-strategy.ts:70`. The port keeps both +sides: `nip56::extract_report_targets` hex-decodes either case +(`crates/nostream-core/src/nip56.rs:165`), and the four arms parse with the types that own +the rule — `EventId`/`Pubkey` for the delete, gift-wrap and timestamp tags, and +`strategies::is_lowercase_hex64` (`src/strategies/mod.rs:185`) only for the group arm's +`h` tag, which has no core type to parse into. + +Normalising either way was rejected. "Lowercase everywhere" would refuse a report the +capture accepts; "insensitive everywhere" would accept a delete, gift wrap, group event or +timestamp the capture refuses — a wire-visible change to which events a relay takes, decided +by a port's taste. The census pins the lowercase side with two explicit cases ("rejects when +the group id contains uppercase hex chars", "rejects upper-case hex in the e tag") and the +core tests pin the report side (`nip56::tests::uppercase_hex_names_the_same_target`). This +is the one place where the same question has two answers in one relay; it is the capture's +answer, and a future decision to change it belongs to a spec, not to a port. + +## The census grew to 658 + +`tests/fixtures/ts-cases.json` is the 658 case titles of the 36 in-scope spec files +(`tests/capture/gen-ts-cases.mjs`, which refuses to write when a file's case count +disagrees with its table), and `tests/fixtures/ts-case-map.json` maps 612 of them across 200 +rows to 182 live Rust tests. The fourteen strategy files contribute 101 cases: 97 are +carried one-to-one by `tests/strategies.rs`, whose names and titles are the map's rows, and +four are exempt with a job-naming reason because the admission cache they assert does not +exist in the port (`crates/nostream-storage/src/cache.rs` is a `pub(crate)` stub). Two +unmapped tests carry the success halves those exemptions would otherwise leave unobserved: +the join admits and answers with the welcome, the leave revokes. + +ADR 0004's census section is corrected to the same figures.