From c9dcc2e5bc3a5b1176fd3098529ef1d3ea39adcd Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 24 Oct 2026 08:56:01 -0400 Subject: [PATCH 01/16] feat(core): port the NIP-03 verifier, NIP-56 targets, report scoring and NIP-43 helpers --- crates/nostream-core/src/nip03.rs | 697 +++++++++++++++++- crates/nostream-core/src/nip43.rs | 68 ++ crates/nostream-core/src/nip56.rs | 322 +++++++- crates/nostream-core/src/report_scoring.rs | 91 ++- crates/nostream-storage/src/error.rs | 16 + .../nostream-storage/src/postgres/events.rs | 23 +- crates/nostream-storage/src/records.rs | 36 + 7 files changed, 1221 insertions(+), 32 deletions(-) diff --git a/crates/nostream-core/src/nip03.rs b/crates/nostream-core/src/nip03.rs index 2a169f8a9..e18304192 100644 --- a/crates/nostream-core/src/nip03.rs +++ b/crates/nostream-core/src/nip03.rs @@ -1,13 +1,696 @@ //! 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::engine::general_purpose::STANDARD; -/// 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, }) } + +/// 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 = STANDARD + .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::*; + + 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(()) + ); + } +} diff --git a/crates/nostream-core/src/nip43.rs b/crates/nostream-core/src/nip43.rs index cc551ece1..cd5b2bb99 100644 --- a/crates/nostream-core/src/nip43.rs +++ b/crates/nostream-core/src/nip43.rs @@ -110,3 +110,71 @@ 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..b9a96de1c 100644 --- a/crates/nostream-core/src/nip56.rs +++ b/crates/nostream-core/src/nip56.rs @@ -1,23 +1,317 @@ //! 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::*; + + fn pubkey_hex() -> String { + "3".repeat(64) + } + + fn event_id_hex() -> String { + "4".repeat(64) + } + + 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 targets = extract_report_targets(&[tag(&["p", &pubkey.to_uppercase()])]); + + 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..fc7cf6eee 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,80 @@ 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). + 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() { + assert_eq!(score_reporter(Some(150), false).weight(), 0.0); + assert_eq!(score_reporter(Some(u32::MAX), false).weight(), 0.0); + } } diff --git a/crates/nostream-storage/src/error.rs b/crates/nostream-storage/src/error.rs index 138208dc4..c2688f326 100644 --- a/crates/nostream-storage/src/error.rs +++ b/crates/nostream-storage/src/error.rs @@ -9,6 +9,14 @@ pub enum StorageError { #[error("not implemented in Phase 1 (job 005): {module}")] NotImplemented { module: &'static str }, + /// The event id already exists, which is the capture's `events_event_id_unique` + /// violation (`crates/nostream-storage/migrations/20220524153400_create_events_table.sql:3`). + /// The two arms that catch it answer `rejected: event already exists` + /// (`replaceable-event-strategy.ts:27`, `contact-list-event-strategy.ts:45`); every other + /// failure is not a policy answer. + #[error("the event id already exists")] + DuplicateEvent, + /// A database operation failed. The driver's message names the statement class for an /// operator; callers must not match on its text, and a handler must never forward it. /// Present only with the `postgres` feature, so the crate's error type names no @@ -118,4 +126,12 @@ mod tests { "database operation failed: no rows returned by a query that expected to return at least one row" ); } + + #[test] + fn a_duplicate_event_says_so_for_an_operator() { + assert_eq!( + StorageError::DuplicateEvent.to_string(), + "the event id already exists" + ); + } } diff --git a/crates/nostream-storage/src/postgres/events.rs b/crates/nostream-storage/src/postgres/events.rs index 0e17d26a6..1c866cfb6 100644 --- a/crates/nostream-storage/src/postgres/events.rs +++ b/crates/nostream-storage/src/postgres/events.rs @@ -35,6 +35,11 @@ event_content, event_signature, remote_address, expires_at, event_deduplication, /// The kind of the capture's `REQUEST_TO_VANISH`, which every retention whitelist keeps. const REQUEST_TO_VANISH: i32 = 62; +/// PostgreSQL's `unique_violation` SQLSTATE, and the constraint the capture's two catching +/// arms match by message. +const EVENT_ID_UNIQUE_VIOLATION_CODE: &str = "23505"; +const EVENT_ID_UNIQUE_CONSTRAINT: &str = "events_event_id_unique"; + /// PostgreSQL's bind limit is 65 535 parameters per statement. One `create` row binds the /// nine insert values; one `upsert` row binds those nine plus the deduplication value /// (`deleted_at` is the literal NULL in the tuple, so its column costs no parameter). These @@ -102,10 +107,26 @@ where Ok(EventQuery { sql, args } .into_query() .execute(executor) - .await? + .await + .map_err(duplicate_event)? .rows_affected()) } +/// `events_event_id_unique` (`migrations/20220524153400_create_events_table.sql:3`), which the +/// replaceable and contact-list arms answer `rejected: event already exists`. Every other +/// failure passes through unchanged: it is not a decision about the publisher. +fn duplicate_event(error: sqlx::Error) -> 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![ From 47a9cf0286331aab6b0973a3e6ef7da1cc09dbda Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 24 Oct 2026 08:56:10 -0400 Subject: [PATCH 02/16] feat(relay): fill the fourteen event strategies behind a web-of-trust seam --- Cargo.lock | 1 + crates/nostream-relay/Cargo.toml | 3 + crates/nostream-relay/src/handlers/event.rs | 2 +- crates/nostream-relay/src/handlers/mod.rs | 7 +- crates/nostream-relay/src/lib.rs | 22 +- .../src/strategies/contact_list.rs | 68 +- .../nostream-relay/src/strategies/delete.rs | 42 +- .../src/strategies/dvm_job_request.rs | 41 +- .../src/strategies/ephemeral.rs | 22 +- .../src/strategies/gift_wrap.rs | 68 +- crates/nostream-relay/src/strategies/group.rs | 61 +- .../src/strategies/invite_request.rs | 32 +- .../src/strategies/join_request.rs | 130 +- .../src/strategies/leave_request.rs | 94 +- crates/nostream-relay/src/strategies/mod.rs | 164 +- .../strategies/parameterized_replaceable.rs | 40 +- .../src/strategies/replaceable.rs | 48 +- .../nostream-relay/src/strategies/report.rs | 100 +- .../src/strategies/timestamp.rs | 98 +- .../nostream-relay/src/strategies/vanish.rs | 35 +- crates/nostream-relay/src/testing.rs | 629 ++++- crates/nostream-relay/src/unavailable.rs | 32 +- crates/nostream-relay/src/wot.rs | 35 + .../tests/capture/gen-ts-cases.mjs | 14 + crates/nostream-relay/tests/strategies.rs | 2201 +++++++++++++++++ 25 files changed, 3636 insertions(+), 353 deletions(-) create mode 100644 crates/nostream-relay/src/wot.rs create mode 100644 crates/nostream-relay/tests/strategies.rs 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-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..77fd70a69 100644 --- a/crates/nostream-relay/src/handlers/mod.rs +++ b/crates/nostream-relay/src/handlers/mod.rs @@ -35,9 +35,10 @@ 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. +/// The repository and service seams a handler may call. Every one is a trait object, so a test +/// supplies in-memory doubles and no handler knows which backend it has. pub struct Stores { pub events: Arc, pub users: Arc, @@ -45,6 +46,8 @@ pub struct Stores { pub invites: Arc, pub dvm_jobs: Arc, pub reports: Arc, + /// The trust graph `contact_list` and `report` reach for, best-effort. + pub wot: Arc, } /// The context one socket shares across handlers (`src/@types/message-handlers.ts`). 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/strategies/contact_list.rs b/crates/nostream-relay/src/strategies/contact_list.rs index f5b03d8ae..8d6f9e170 100644 --- a/crates/nostream-relay/src/strategies/contact_list.rs +++ b/crates/nostream-relay/src/strategies/contact_list.rs @@ -1,29 +1,79 @@ -//! 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 + .stores + .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..50371cd50 100644 --- a/crates/nostream-relay/src/strategies/delete.rs +++ b/crates/nostream-relay/src/strategies/delete.rs @@ -1,29 +1,55 @@ //! The `delete` event strategy. Mirrors `src/handlers/event-strategies/delete-event-strategy.ts`. +//! +//! A NIP-09 request: every `e` tag whose value is 64 lowercase hex characters is soft-deleted +//! **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 crate::error::RelayError; use crate::handlers::HandlerContext; -use crate::strategies::EventStrategy; +use crate::strategies::{EventStrategy, event_record, is_lowercase_hex64}; /// 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" && is_lowercase_hex64(&tag[1])) + .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..45b1473ce 100644 --- a/crates/nostream-relay/src/strategies/dvm_job_request.rs +++ b/crates/nostream-relay/src/strategies/dvm_job_request.rs @@ -1,29 +1,52 @@ -//! 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..868d16259 100644 --- a/crates/nostream-relay/src/strategies/gift_wrap.rs +++ b/crates/nostream-relay/src/strategies/gift_wrap.rs @@ -1,29 +1,79 @@ -//! 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 crate::error::RelayError; use crate::handlers::HandlerContext; -use crate::strategies::EventStrategy; +use crate::strategies::{EventStrategy, event_record, is_lowercase_hex64}; /// 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 !is_lowercase_hex64(&recipients[0][1]) { + 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..6b37c7109 100644 --- a/crates/nostream-relay/src/strategies/group.rs +++ b/crates/nostream-relay/src/strategies/group.rs @@ -1,29 +1,74 @@ //! 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..364d0ecaf 100644 --- a/crates/nostream-relay/src/strategies/join_request.rs +++ b/crates/nostream-relay/src/strategies/join_request.rs @@ -1,29 +1,141 @@ -//! 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..90a70bd66 100644 --- a/crates/nostream-relay/src/strategies/leave_request.rs +++ b/crates/nostream-relay/src/strategies/leave_request.rs @@ -1,29 +1,105 @@ -//! 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..d3b1b0975 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,27 @@ 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 rule the `delete`, +/// `gift_wrap`, `group` and `timestamp` arms apply to theirs. 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 +241,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..8da4102da 100644 --- a/crates/nostream-relay/src/strategies/parameterized_replaceable.rs +++ b/crates/nostream-relay/src/strategies/parameterized_replaceable.rs @@ -1,29 +1,51 @@ -//! 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..00920d6a7 100644 --- a/crates/nostream-relay/src/strategies/report.rs +++ b/crates/nostream-relay/src/strategies/report.rs @@ -1,29 +1,111 @@ //! 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.stores.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..cb057ec0f 100644 --- a/crates/nostream-relay/src/strategies/timestamp.rs +++ b/crates/nostream-relay/src/strategies/timestamp.rs @@ -1,29 +1,109 @@ -//! 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 crate::error::RelayError; use crate::handlers::HandlerContext; -use crate::strategies::EventStrategy; +use crate::strategies::{EventStrategy, event_record, is_lowercase_hex64}; /// 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]; + + if !is_lowercase_hex64(target) { + 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()); + } + + let target_event_id = target.parse().ok()?; + + 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..9cd4e518a 100644 --- a/crates/nostream-relay/src/strategies/vanish.rs +++ b/crates/nostream-relay/src/strategies/vanish.rs @@ -1,29 +1,46 @@ //! 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..dc817f765 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,59 @@ 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 +501,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 +567,7 @@ impl EventRepository for InMemoryEventRepository { #[derive(Debug, Default)] pub struct InMemoryUserRepository { users: Mutex>, + vanish_calls: Mutex>, fail_reads: AtomicBool, } @@ -433,6 +589,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 +663,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 +820,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 +891,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 +928,21 @@ impl InviteCodeRepository for InMemoryInviteCodeRepository { let _ = gate.acquire().await; } + let row = InviteCodeRow { + options: options.clone(), + claimed_by: None, + remaining_uses: match options.remaining_uses { + FieldUpdate::Set(uses) => Some(uses), + FieldUpdate::Absent | FieldUpdate::Clear => None, + }, + }; + 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 +950,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 +961,60 @@ 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 when it is absent, expired, claimed by someone else, or + /// out of uses (`claimCode`, `src/repositories/invite-code-repository.ts`). + 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 + .claimed_by + .is_some_and(|claimed_by| claimed_by != *pubkey) + { + 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 = Some(*pubkey); + + Ok(true) + }) } fn find_active_codes<'a>(&'a self, _limit: u64) -> StorageFuture<'a, Vec> { @@ -762,14 +1026,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 +1061,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 +1080,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 +1127,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 +1161,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 +1255,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; @@ -929,21 +1391,24 @@ impl crate::limits::Limiter for FailingLimiter { } } -/// The six doubles as one [`Stores`] value. +/// The six doubles and the trust-graph recorder as one [`Stores`] value. pub fn stores( events: Arc, users: Arc, nip05: Arc, invites: Arc, reports: Arc, + dvm: Arc, + wot: Arc, ) -> Arc { Arc::new(Stores { events, users, nip05, invites, - dvm_jobs: InMemoryDvmJobRepository::new(), + dvm_jobs: dvm, reports, + wot, }) } @@ -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,12 +1447,16 @@ impl Fixture { Arc::clone(&nip05), Arc::clone(&invites), Arc::clone(&reports), + Arc::clone(&dvm), + Arc::clone(&wot), ), events, users, nip05, invites, reports, + dvm, + wot, hidden: HiddenContentCache::new(), hub, } diff --git a/crates/nostream-relay/src/unavailable.rs b/crates/nostream-relay/src/unavailable.rs index 2bbaa1013..4e243b4fe 100644 --- a/crates/nostream-relay/src/unavailable.rs +++ b/crates/nostream-relay/src/unavailable.rs @@ -17,9 +17,11 @@ use nostream_storage::repositories::{ StorageFuture, User, UserPatch, UserRepository, }; +use crate::error::RelayError; use crate::handlers::Stores; +use crate::wot::{WotFuture, WotGraph}; -/// The six seams, every call reporting `NotImplemented`. +/// The six stores and the trust graph, every call reporting `NotImplemented`. pub fn stores() -> Stores { Stores { events: std::sync::Arc::new(UnavailableEventRepository), @@ -28,6 +30,7 @@ pub fn stores() -> Stores { invites: std::sync::Arc::new(UnavailableInviteCodeRepository), dvm_jobs: std::sync::Arc::new(UnavailableDvmJobRepository), reports: std::sync::Arc::new(UnavailableReportRepository), + wot: std::sync::Arc::new(UnavailableWotGraph), } } @@ -37,6 +40,33 @@ fn unavailable() -> StorageError { } } +/// The trust graph, for a daemon with no `nostream-services` wiring: the contact-list and +/// report arms treat it as best-effort, so every refusal here is logged and the event stands. +#[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, ()> { + Box::pin(async { + Err(RelayError::NotImplemented { + module: "relay::wot::update_follow_list", + }) + }) + } + + fn get_distance<'a>(&'a self, _pubkey: &'a Pubkey) -> WotFuture<'a, Option> { + Box::pin(async { + Err(RelayError::NotImplemented { + module: "relay::wot::get_distance", + }) + }) + } +} + /// 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..8f8746802 --- /dev/null +++ b/crates/nostream-relay/src/wot.rs @@ -0,0 +1,35 @@ +//! 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. 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..364f38469 100644 --- a/crates/nostream-relay/tests/capture/gen-ts-cases.mjs +++ b/crates/nostream-relay/tests/capture/gen-ts-cases.mjs @@ -30,7 +30,21 @@ const FILES = [ ['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/strategies.rs b/crates/nostream-relay/tests/strategies.rs new file mode 100644 index 000000000..6605e1041 --- /dev/null +++ b/crates/nostream-relay/tests/strategies.rs @@ -0,0 +1,2201 @@ +//! 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::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::strategies::EventStrategy; +use nostream_relay::testing::{ + Fixture, UpsertFailure, hub_receiver, nip43_settings, settings, +}; +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_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:")]); +} From c4ecefbfb563951aac9abebe0cc46a62b55f55e4 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 24 Oct 2026 09:01:45 -0400 Subject: [PATCH 03/16] test(relay): carry the fourteen strategy spec files into the census --- .../tests/capture/gen-ts-cases.mjs | 4 +- .../tests/fixtures/ts-case-map.json | 697 +++++++++++++++++- .../tests/fixtures/ts-cases.json | 187 ++++- crates/nostream-relay/tests/ts_case_parity.rs | 62 +- 4 files changed, 943 insertions(+), 7 deletions(-) diff --git a/crates/nostream-relay/tests/capture/gen-ts-cases.mjs b/crates/nostream-relay/tests/capture/gen-ts-cases.mjs index 364f38469..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,7 +23,7 @@ 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], 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/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" ); } From 1a9d6f8c50485889f3d027303298677fc0c7e4c1 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 24 Oct 2026 09:03:50 -0400 Subject: [PATCH 04/16] docs(rewrite): record the event strategies in ADR 0006 and move ADR 0004's census --- .changeset/relay-event-strategies.md | 23 +++ docs/rewrite/decisions/0004-wire-transport.md | 6 +- .../decisions/0006-event-strategies.md | 132 ++++++++++++++++++ 3 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 .changeset/relay-event-strategies.md create mode 100644 docs/rewrite/decisions/0006-event-strategies.md diff --git a/.changeset/relay-event-strategies.md b/.changeset/relay-event-strategies.md new file mode 100644 index 000000000..426d9b9a6 --- /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 99 tests. Decisions are recorded in +`docs/rewrite/decisions/0006-event-strategies.md`. 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..02270122c --- /dev/null +++ b/docs/rewrite/decisions/0006-event-strategies.md @@ -0,0 +1,132 @@ +# 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:24` declares `WotGraph` beside `limits::Limiter`, +which is the file's existing precedent for a service the relay needs and does not own. + +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:46`) answers both methods `NotImplemented`, so +the daemon in `unavailable::stores()` still serves; both arms treat the seam as +best-effort, so a graph that is not wired never rejects an event. The consequence is +recorded where it bites: ADR 0004's adaptive-PoW section already says a missing distance is +charged in full, and the report arm stores an unweighted row for a reporter it cannot +place. + +## 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:110`), 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:191`), 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:12-15`). + +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 share +`strategies::is_lowercase_hex64` (`src/strategies/mod.rs:183`). + +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. From 753cfb64a9bbad5ba64c971c7499d5121e34dfd8 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 24 Oct 2026 09:04:26 -0400 Subject: [PATCH 05/16] style: rustfmt the new strategy, core and test modules --- crates/nostream-core/src/nip03.rs | 17 +- crates/nostream-core/src/nip43.rs | 5 +- crates/nostream-core/src/nip56.rs | 16 +- .../src/strategies/contact_list.rs | 7 +- .../nostream-relay/src/strategies/delete.rs | 7 +- .../src/strategies/dvm_job_request.rs | 7 +- .../src/strategies/gift_wrap.rs | 7 +- crates/nostream-relay/src/strategies/group.rs | 7 +- .../src/strategies/join_request.rs | 5 +- .../src/strategies/leave_request.rs | 11 +- .../strategies/parameterized_replaceable.rs | 7 +- .../nostream-relay/src/strategies/report.rs | 14 +- .../src/strategies/timestamp.rs | 11 +- .../nostream-relay/src/strategies/vanish.rs | 13 +- crates/nostream-relay/src/testing.rs | 3 +- crates/nostream-relay/tests/strategies.rs | 174 ++++++++++-------- 16 files changed, 179 insertions(+), 132 deletions(-) diff --git a/crates/nostream-core/src/nip03.rs b/crates/nostream-core/src/nip03.rs index e18304192..3f7aa1100 100644 --- a/crates/nostream-core/src/nip03.rs +++ b/crates/nostream-core/src/nip03.rs @@ -22,13 +22,10 @@ const MAGIC_HEADER: [u8; 31] = [ ]; /// 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 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]; +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; @@ -179,7 +176,8 @@ pub fn validate_ots_proof(base64_content: &str, target_event_id: &EventId) -> Re .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}"))?; + let summary = + parse_ots_file(&bytes).map_err(|reason| format!("invalid ots proof: {reason}"))?; if summary.file_hash_op != FileHashOp::Sha256 { return Err(format!( @@ -676,9 +674,8 @@ mod tests { #[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", - ); + 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()), diff --git a/crates/nostream-core/src/nip43.rs b/crates/nostream-core/src/nip43.rs index cd5b2bb99..a5f93a3c7 100644 --- a/crates/nostream-core/src/nip43.rs +++ b/crates/nostream-core/src/nip43.rs @@ -128,10 +128,7 @@ 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 + now_secs.saturating_sub(created_at).saturating_abs() <= MAX_TIMESTAMP_DELTA_SECONDS } #[cfg(test)] diff --git a/crates/nostream-core/src/nip56.rs b/crates/nostream-core/src/nip56.rs index b9a96de1c..aa5951571 100644 --- a/crates/nostream-core/src/nip56.rs +++ b/crates/nostream-core/src/nip56.rs @@ -104,9 +104,7 @@ pub fn extract_report_targets(tags: &[Tag]) -> Vec { _ => 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), + report_type: event_type.or(pubkey_type).unwrap_or(ReportType::Other), }), } } else if let Some(event_id) = event_ids.first() { @@ -197,10 +195,8 @@ mod tests { 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"]), - ]); + let targets = + extract_report_targets(&[tag(&["p", &pubkey, "spam"]), tag(&["e", &event_id, "spam"])]); assert_eq!( targets, @@ -242,10 +238,8 @@ mod tests { 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"]), - ]); + 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); diff --git a/crates/nostream-relay/src/strategies/contact_list.rs b/crates/nostream-relay/src/strategies/contact_list.rs index 8d6f9e170..ecf5c2b5a 100644 --- a/crates/nostream-relay/src/strategies/contact_list.rs +++ b/crates/nostream-relay/src/strategies/contact_list.rs @@ -43,7 +43,12 @@ impl EventStrategy for ContactListStrategy { Err(error) => return Err(error.into()), }; - context.send_ok(event.id, true, if count > 0 { "" } else { "duplicate:" }, None); + context.send_ok( + event.id, + true, + if count > 0 { "" } else { "duplicate:" }, + None, + ); if count == 0 { return Ok(()); diff --git a/crates/nostream-relay/src/strategies/delete.rs b/crates/nostream-relay/src/strategies/delete.rs index 50371cd50..749f60caa 100644 --- a/crates/nostream-relay/src/strategies/delete.rs +++ b/crates/nostream-relay/src/strategies/delete.rs @@ -43,7 +43,12 @@ impl EventStrategy for DeleteStrategy { 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); + context.send_ok( + event.id, + true, + if count > 0 { "" } else { "duplicate:" }, + None, + ); if count > 0 { let _ = context.broadcast.send(Arc::new(event.clone())); diff --git a/crates/nostream-relay/src/strategies/dvm_job_request.rs b/crates/nostream-relay/src/strategies/dvm_job_request.rs index 45b1473ce..a91cebf59 100644 --- a/crates/nostream-relay/src/strategies/dvm_job_request.rs +++ b/crates/nostream-relay/src/strategies/dvm_job_request.rs @@ -29,7 +29,12 @@ impl EventStrategy for DvmJobRequestStrategy { 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); + context.send_ok( + event.id, + true, + if count > 0 { "" } else { "duplicate:" }, + None, + ); if count == 0 { return Ok(()); diff --git a/crates/nostream-relay/src/strategies/gift_wrap.rs b/crates/nostream-relay/src/strategies/gift_wrap.rs index 868d16259..84a2513ca 100644 --- a/crates/nostream-relay/src/strategies/gift_wrap.rs +++ b/crates/nostream-relay/src/strategies/gift_wrap.rs @@ -37,7 +37,12 @@ impl EventStrategy for GiftWrapStrategy { 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); + context.send_ok( + event.id, + true, + if count > 0 { "" } else { "duplicate:" }, + None, + ); if count > 0 { let _ = context.broadcast.send(Arc::new(event.clone())); diff --git a/crates/nostream-relay/src/strategies/group.rs b/crates/nostream-relay/src/strategies/group.rs index 6b37c7109..c2ae0f1b1 100644 --- a/crates/nostream-relay/src/strategies/group.rs +++ b/crates/nostream-relay/src/strategies/group.rs @@ -36,7 +36,12 @@ impl EventStrategy for GroupStrategy { 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); + context.send_ok( + event.id, + true, + if count > 0 { "" } else { "duplicate:" }, + None, + ); if count > 0 { let _ = context.broadcast.send(Arc::new(event.clone())); diff --git a/crates/nostream-relay/src/strategies/join_request.rs b/crates/nostream-relay/src/strategies/join_request.rs index 364d0ecaf..d746bf52e 100644 --- a/crates/nostream-relay/src/strategies/join_request.rs +++ b/crates/nostream-relay/src/strategies/join_request.rs @@ -59,10 +59,7 @@ impl EventStrategy for JoinRequestStrategy { return Ok(()); } - if !context - .authenticated_pubkeys() - .contains(&event.pubkey) - { + if !context.authenticated_pubkeys().contains(&event.pubkey) { context.send_ok( event.id, false, diff --git a/crates/nostream-relay/src/strategies/leave_request.rs b/crates/nostream-relay/src/strategies/leave_request.rs index 90a70bd66..fc9f0ed43 100644 --- a/crates/nostream-relay/src/strategies/leave_request.rs +++ b/crates/nostream-relay/src/strategies/leave_request.rs @@ -69,10 +69,7 @@ impl EventStrategy for LeaveRequestStrategy { return Ok(()); } - if !context - .authenticated_pubkeys() - .contains(&event.pubkey) - { + if !context.authenticated_pubkeys().contains(&event.pubkey) { context.send_ok( event.id, false, @@ -91,11 +88,7 @@ impl EventStrategy for LeaveRequestStrategy { return Ok(()); } - context - .stores - .users - .revoke_admission(&event.pubkey) - .await?; + context.stores.users.revoke_admission(&event.pubkey).await?; context.send_ok(event.id, true, "", None); diff --git a/crates/nostream-relay/src/strategies/parameterized_replaceable.rs b/crates/nostream-relay/src/strategies/parameterized_replaceable.rs index 8da4102da..512891d79 100644 --- a/crates/nostream-relay/src/strategies/parameterized_replaceable.rs +++ b/crates/nostream-relay/src/strategies/parameterized_replaceable.rs @@ -39,7 +39,12 @@ impl EventStrategy for ParameterizedReplaceableStrategy { .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); + context.send_ok( + event.id, + true, + if count > 0 { "" } else { "duplicate:" }, + None, + ); if count > 0 { let _ = context.broadcast.send(Arc::new(event.clone())); diff --git a/crates/nostream-relay/src/strategies/report.rs b/crates/nostream-relay/src/strategies/report.rs index 00920d6a7..053637b0c 100644 --- a/crates/nostream-relay/src/strategies/report.rs +++ b/crates/nostream-relay/src/strategies/report.rs @@ -34,7 +34,12 @@ impl EventStrategy for ReportStrategy { 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); + context.send_ok( + event.id, + true, + if count > 0 { "" } else { "duplicate:" }, + None, + ); if count == 0 { return Ok(()); @@ -57,7 +62,12 @@ impl EventStrategy for ReportStrategy { /// 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 { + let Some(nip56) = context + .settings + .nip56 + .as_ref() + .filter(|nip56| nip56.enabled) + else { return Ok(()); }; diff --git a/crates/nostream-relay/src/strategies/timestamp.rs b/crates/nostream-relay/src/strategies/timestamp.rs index cb057ec0f..2a9161f5b 100644 --- a/crates/nostream-relay/src/strategies/timestamp.rs +++ b/crates/nostream-relay/src/strategies/timestamp.rs @@ -39,7 +39,12 @@ impl EventStrategy for TimestampStrategy { 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); + context.send_ok( + event.id, + true, + if count > 0 { "" } else { "duplicate:" }, + None, + ); if count > 0 { let _ = context.broadcast.send(Arc::new(event.clone())); @@ -68,7 +73,9 @@ fn validate(event: &Event) -> Option { 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()); + return Some( + "opentimestamps event (kind 1040) must reference exactly one event".to_owned(), + ); } let target = &attested[0][1]; diff --git a/crates/nostream-relay/src/strategies/vanish.rs b/crates/nostream-relay/src/strategies/vanish.rs index 9cd4e518a..649c489a2 100644 --- a/crates/nostream-relay/src/strategies/vanish.rs +++ b/crates/nostream-relay/src/strategies/vanish.rs @@ -36,9 +36,18 @@ impl EventStrategy for VanishStrategy { let record = event_record(event, context); let count = context.stores.events.create(&record).await?; - context.stores.users.set_vanished(&event.pubkey, true).await?; + context + .stores + .users + .set_vanished(&event.pubkey, true) + .await?; - context.send_ok(event.id, true, if count > 0 { "" } else { "duplicate:" }, None); + 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 dc817f765..0559dc7b2 100644 --- a/crates/nostream-relay/src/testing.rs +++ b/crates/nostream-relay/src/testing.rs @@ -365,7 +365,8 @@ impl EventRepository for InMemoryEventRepository { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - if record.event.kind.is_replaceable() || record.event.kind.is_parameterized_replaceable() + 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 diff --git a/crates/nostream-relay/tests/strategies.rs b/crates/nostream-relay/tests/strategies.rs index 6605e1041..4fdc4335f 100644 --- a/crates/nostream-relay/tests/strategies.rs +++ b/crates/nostream-relay/tests/strategies.rs @@ -27,6 +27,7 @@ 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; @@ -41,10 +42,7 @@ 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::strategies::EventStrategy; -use nostream_relay::testing::{ - Fixture, UpsertFailure, hub_receiver, nip43_settings, settings, -}; +use nostream_relay::testing::{Fixture, UpsertFailure, hub_receiver, nip43_settings, settings}; use nostream_storage::repositories::{ CreateInviteCodeOptions, EventRecord, EventRepository, FieldUpdate, InviteCodeRepository, NewReport, ReportType, User, UserRepository, @@ -107,10 +105,7 @@ fn socket(fixture: &Fixture) -> (HandlerContext, mpsc::UnboundedReceiver Nip42Session::with_challenge("challenge", 0), )); - ( - fixture.context(clock(), keys(), connection, sink), - frames, - ) + (fixture.context(clock(), keys(), connection, sink), frames) } /// The same, with the socket authenticated as `pubkey`. @@ -259,7 +254,10 @@ async fn contact_list_does_not_broadcast_or_update_the_wot_graph_when_the_event_ ContactListStrategy.execute(&event, &context).await.unwrap(); - assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "duplicate:")]); + 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()); } @@ -321,10 +319,7 @@ async fn contact_list_returns_an_error_on_an_unexpected_upsert_failure() { fn delete_event() -> Event { event( 5, - vec![ - tag(&["e", &"0".repeat(64)]), - tag(&["e", &"f".repeat(64)]), - ], + vec![tag(&["e", &"0".repeat(64)]), tag(&["e", &"f".repeat(64)])], "", ) } @@ -418,7 +413,10 @@ async fn delete_does_not_broadcast_when_the_event_is_a_duplicate() { DeleteStrategy.execute(&event, &context).await.unwrap(); - assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "duplicate:")]); + assert_eq!( + frames(&mut sink), + vec![ok_frame(&event, true, "duplicate:")] + ); assert_eq!(broadcast(&mut hub), None); } @@ -514,7 +512,10 @@ async fn dvm_job_request_does_not_broadcast_or_record_a_job_when_the_event_is_a_ .await .unwrap(); - assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "duplicate:")]); + assert_eq!( + frames(&mut sink), + vec![ok_frame(&event, true, "duplicate:")] + ); assert_eq!(fixture.dvm.jobs().len(), 0); assert_eq!(broadcast(&mut hub), None); } @@ -542,9 +543,7 @@ async fn dvm_job_request_returns_an_error_when_the_create_fails() { fixture.events.fail_create(true); let context = fixture.anonymous_context(clock(), keys()); - let outcome = DvmJobRequestStrategy - .execute(&dvm_event(), &context) - .await; + let outcome = DvmJobRequestStrategy.execute(&dvm_event(), &context).await; assert!(matches!(outcome, Err(RelayError::Storage(_)))); assert_eq!(fixture.dvm.jobs().len(), 0); @@ -574,11 +573,7 @@ async fn ephemeral_sends_ok_and_broadcasts_without_storing() { /// 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(), - ) + event(1059, vec![tag(&["p", &hex(0xaa, 64)])], &nip44_payload()) } #[tokio::test] @@ -616,7 +611,10 @@ async fn gift_wrap_answers_duplicate_without_broadcasting() { GiftWrapStrategy.execute(&event, &context).await.unwrap(); - assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "duplicate:")]); + assert_eq!( + frames(&mut sink), + vec![ok_frame(&event, true, "duplicate:")] + ); assert_eq!(broadcast(&mut hub), None); } @@ -645,10 +643,7 @@ 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)]), - ]; + event.tags = vec![tag(&["p", &hex(0xaa, 64)]), tag(&["p", &hex(0xbb, 64)])]; GiftWrapStrategy.execute(&event, &context).await.unwrap(); @@ -668,11 +663,7 @@ 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", - ])]; + event.tags = vec![tag(&["p", &hex(0xaa, 64), "wss://inbox.example.com"])]; GiftWrapStrategy.execute(&event, &context).await.unwrap(); @@ -787,7 +778,10 @@ async fn group_answers_duplicate_without_broadcasting() { GroupStrategy.execute(&event, &context).await.unwrap(); - assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "duplicate:")]); + assert_eq!( + frames(&mut sink), + vec![ok_frame(&event, true, "duplicate:")] + ); assert_eq!(broadcast(&mut hub), None); } @@ -852,10 +846,7 @@ 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)]), - ]; + event.tags = vec![tag(&["h", &hex(0xaa, 64)]), tag(&["h", &hex(0xbb, 64)])]; GroupStrategy.execute(&event, &context).await.unwrap(); @@ -968,11 +959,7 @@ async fn group_accepts_lowercase_hex() { // --------------------------------------------------------------------------- fn invite_request_event() -> Event { - event( - 28_935, - vec![tag(&["claim", "forged-code"])], - "", - ) + event(28_935, vec![tag(&["claim", "forged-code"])], "") } #[tokio::test] @@ -1017,11 +1004,7 @@ async fn invite_request_never_broadcasts() { // --------------------------------------------------------------------------- fn join_request_event() -> Event { - event( - 28_934, - vec![tag(&["claim", "valid-code"])], - "", - ) + event(28_934, vec![tag(&["claim", "valid-code"])], "") } #[tokio::test] @@ -1185,7 +1168,10 @@ async fn the_join_request_admits_and_answers_with_the_welcome() { .unwrap(); assert_eq!(user.map(|user| user.is_admitted), Some(true)); - assert_eq!(fixture.invites.claims(), vec![("valid-code".to_owned(), author())]); + assert_eq!( + fixture.invites.claims(), + vec![("valid-code".to_owned(), author())] + ); } // --------------------------------------------------------------------------- @@ -1213,7 +1199,10 @@ async fn leave_request_refuses_when_nip43_is_disabled() { let (context, mut sink) = authenticated_socket(&fixture, author()); let event = leave_request_event(); - LeaveRequestStrategy.execute(&event, &context).await.unwrap(); + LeaveRequestStrategy + .execute(&event, &context) + .await + .unwrap(); assert_eq!( frames(&mut sink), @@ -1234,7 +1223,10 @@ async fn leave_request_refuses_a_request_without_the_protected_tag() { let mut event = leave_request_event(); event.tags = Vec::new(); - LeaveRequestStrategy.execute(&event, &context).await.unwrap(); + LeaveRequestStrategy + .execute(&event, &context) + .await + .unwrap(); assert_eq!( frames(&mut sink), @@ -1261,7 +1253,10 @@ async fn leave_request_refuses_a_stale_created_at() { let mut event = leave_request_event(); event.created_at = NOW_SECS - 3600; - LeaveRequestStrategy.execute(&event, &context).await.unwrap(); + LeaveRequestStrategy + .execute(&event, &context) + .await + .unwrap(); assert_eq!( frames(&mut sink), @@ -1281,7 +1276,10 @@ async fn leave_request_refuses_an_unauthenticated_request() { let (context, mut sink) = socket(&fixture); let event = leave_request_event(); - LeaveRequestStrategy.execute(&event, &context).await.unwrap(); + LeaveRequestStrategy + .execute(&event, &context) + .await + .unwrap(); assert_eq!( frames(&mut sink), @@ -1299,7 +1297,10 @@ async fn leave_request_answers_ok_when_the_user_is_unknown() { let (context, mut sink) = authenticated_socket(&fixture, author()); let event = leave_request_event(); - LeaveRequestStrategy.execute(&event, &context).await.unwrap(); + LeaveRequestStrategy + .execute(&event, &context) + .await + .unwrap(); assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "")]); @@ -1321,7 +1322,10 @@ async fn leave_request_answers_ok_when_the_user_is_not_admitted() { let (context, mut sink) = authenticated_socket(&fixture, author()); let event = leave_request_event(); - LeaveRequestStrategy.execute(&event, &context).await.unwrap(); + LeaveRequestStrategy + .execute(&event, &context) + .await + .unwrap(); assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "")]); } @@ -1335,7 +1339,10 @@ async fn the_leave_request_revokes_the_admission() { let (context, mut sink) = authenticated_socket(&fixture, author()); let event = leave_request_event(); - LeaveRequestStrategy.execute(&event, &context).await.unwrap(); + LeaveRequestStrategy + .execute(&event, &context) + .await + .unwrap(); assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "")]); @@ -1435,9 +1442,15 @@ async fn parameterized_replaceable_does_not_broadcast_a_duplicate() { .await .unwrap(); - assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "duplicate:")]); + 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")]]); + assert_eq!( + fixture.events.upserts(), + vec![vec![json!("one")], vec![json!("one")]] + ); } #[tokio::test] @@ -1501,7 +1514,10 @@ async fn replaceable_does_not_broadcast_when_the_stored_row_wins() { ReplaceableStrategy.execute(&event, &context).await.unwrap(); - assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "duplicate:")]); + assert_eq!( + frames(&mut sink), + vec![ok_frame(&event, true, "duplicate:")] + ); assert_eq!(broadcast(&mut hub), None); } @@ -1597,7 +1613,10 @@ async fn report_does_not_broadcast_or_record_a_duplicate() { ReportStrategy.execute(&event, &context).await.unwrap(); - assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "duplicate:")]); + assert_eq!( + frames(&mut sink), + vec![ok_frame(&event, true, "duplicate:")] + ); assert_eq!(broadcast(&mut hub), None); assert_eq!(fixture.reports.reports(), Vec::new()); } @@ -1614,10 +1633,7 @@ async fn report_records_full_weight_for_a_direct_follow() { .await .unwrap(); - assert_eq!( - fixture.reports.reports(), - vec![expected_report(1.0, false)] - ); + assert_eq!(fixture.reports.reports(), vec![expected_report(1.0, false)]); assert_eq!(fixture.wot.distance_queries(), vec![author()]); } @@ -1631,10 +1647,7 @@ async fn report_records_zero_weight_outside_the_graph() { .await .unwrap(); - assert_eq!( - fixture.reports.reports(), - vec![expected_report(0.0, false)] - ); + assert_eq!(fixture.reports.reports(), vec![expected_report(0.0, false)]); assert_eq!(fixture.wot.distance_queries(), vec![author()]); } @@ -1648,10 +1661,7 @@ async fn report_records_a_max_weight_actionable_row_for_a_moderator() { .await .unwrap(); - assert_eq!( - fixture.reports.reports(), - vec![expected_report(1.0, true)] - ); + assert_eq!(fixture.reports.reports(), vec![expected_report(1.0, true)]); } #[tokio::test] @@ -1907,7 +1917,10 @@ async fn timestamp_answers_duplicate_without_broadcasting() { TimestampStrategy.execute(&event, &context).await.unwrap(); - assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "duplicate:")]); + assert_eq!( + frames(&mut sink), + vec![ok_frame(&event, true, "duplicate:")] + ); assert_eq!(broadcast(&mut hub), None); } @@ -2035,10 +2048,7 @@ 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"]), - ]; + event.tags = vec![tag(&["e", &attested_id().to_string()]), tag(&["k", "-1"])]; TimestampStrategy.execute(&event, &context).await.unwrap(); @@ -2102,9 +2112,8 @@ 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", - ); + 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(); @@ -2197,5 +2206,8 @@ async fn vanish_answers_duplicate_when_the_request_exists() { VanishStrategy.execute(&event, &context).await.unwrap(); - assert_eq!(frames(&mut sink), vec![ok_frame(&event, true, "duplicate:")]); + assert_eq!( + frames(&mut sink), + vec![ok_frame(&event, true, "duplicate:")] + ); } From c00aaf1b6a32453b9e22a5e36fdbb1b8d002e6e3 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 24 Oct 2026 09:29:03 -0400 Subject: [PATCH 06/16] fix(core): assert the report weight's zero boundary where it is --- crates/nostream-core/src/report_scoring.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/nostream-core/src/report_scoring.rs b/crates/nostream-core/src/report_scoring.rs index fc7cf6eee..99dc8a3bd 100644 --- a/crates/nostream-core/src/report_scoring.rs +++ b/crates/nostream-core/src/report_scoring.rs @@ -28,7 +28,10 @@ impl ReportWeight { 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). + // 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 { @@ -89,7 +92,13 @@ mod tests { #[test] fn a_very_large_distance_underflows_to_zero() { - assert_eq!(score_reporter(Some(150), false).weight(), 0.0); + // 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); } } From 9376a69711a09b7de1f934deeb11849d53fdb91d Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 24 Oct 2026 09:29:03 -0400 Subject: [PATCH 07/16] fix(relay): treat an unwired wot graph as no distance, not a failure --- crates/nostream-relay/src/handlers/mod.rs | 4 ++- crates/nostream-relay/src/unavailable.rs | 28 +++++++++---------- crates/nostream-relay/tests/strategies.rs | 22 ++++++++++++++- .../decisions/0006-event-strategies.md | 15 ++++++---- 4 files changed, 47 insertions(+), 22 deletions(-) diff --git a/crates/nostream-relay/src/handlers/mod.rs b/crates/nostream-relay/src/handlers/mod.rs index 77fd70a69..8aff4171d 100644 --- a/crates/nostream-relay/src/handlers/mod.rs +++ b/crates/nostream-relay/src/handlers/mod.rs @@ -38,7 +38,9 @@ use crate::sink::FrameSink; use crate::wot::WotGraph; /// The repository and service 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. +/// supplies in-memory doubles and no handler knows which backend it has. Cheap to clone: every +/// field is an `Arc`, so a test can copy a context's stores and swap the one seam it is about. +#[derive(Clone)] pub struct Stores { pub events: Arc, pub users: Arc, diff --git a/crates/nostream-relay/src/unavailable.rs b/crates/nostream-relay/src/unavailable.rs index 4e243b4fe..995efb6c3 100644 --- a/crates/nostream-relay/src/unavailable.rs +++ b/crates/nostream-relay/src/unavailable.rs @@ -17,11 +17,11 @@ use nostream_storage::repositories::{ StorageFuture, User, UserPatch, UserRepository, }; -use crate::error::RelayError; use crate::handlers::Stores; use crate::wot::{WotFuture, WotGraph}; -/// The six stores and the trust graph, every call reporting `NotImplemented`. +/// The six stores, every call reporting `NotImplemented`, and the trust graph, which has no +/// graph to read. pub fn stores() -> Stores { Stores { events: std::sync::Arc::new(UnavailableEventRepository), @@ -40,8 +40,11 @@ fn unavailable() -> StorageError { } } -/// The trust graph, for a daemon with no `nostream-services` wiring: the contact-list and -/// report arms treat it as best-effort, so every refusal here is logged and the event stands. +/// The trust graph, for a daemon with no `nostream-services` wiring. "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; @@ -51,19 +54,16 @@ impl WotGraph for UnavailableWotGraph { _pubkey: &'a Pubkey, _follows: &'a [String], ) -> WotFuture<'a, ()> { - Box::pin(async { - Err(RelayError::NotImplemented { - module: "relay::wot::update_follow_list", - }) - }) + // `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> { - Box::pin(async { - Err(RelayError::NotImplemented { - module: "relay::wot::get_distance", - }) - }) + // `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) }) } } diff --git a/crates/nostream-relay/tests/strategies.rs b/crates/nostream-relay/tests/strategies.rs index 4fdc4335f..0a573da42 100644 --- a/crates/nostream-relay/tests/strategies.rs +++ b/crates/nostream-relay/tests/strategies.rs @@ -24,7 +24,7 @@ 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::handlers::{HandlerContext, Stores}; use nostream_relay::relay_key::RelayKeySource; use nostream_relay::sink::FrameSink; use nostream_relay::strategies::EventStrategy; @@ -43,6 +43,7 @@ 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, @@ -1651,6 +1652,25 @@ async fn report_records_zero_weight_outside_the_graph() { 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.stores = Arc::new(Stores { + wot: Arc::new(UnavailableWotGraph), + ..(*context.stores).clone() + }); + + // 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)); diff --git a/docs/rewrite/decisions/0006-event-strategies.md b/docs/rewrite/decisions/0006-event-strategies.md index 02270122c..fc0b078e7 100644 --- a/docs/rewrite/decisions/0006-event-strategies.md +++ b/docs/rewrite/decisions/0006-event-strategies.md @@ -35,12 +35,15 @@ The shapes rejected: 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:46`) answers both methods `NotImplemented`, so -the daemon in `unavailable::stores()` still serves; both arms treat the seam as -best-effort, so a graph that is not wired never rejects an event. The consequence is -recorded where it bites: ADR 0004's adaptive-PoW section already says a missing distance is -charged in full, and the report arm stores an unweighted row for a reporter it cannot -place. +`UnavailableWotGraph` (`src/unavailable.rs:44-69`) 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 From 3ed5b3daf24111ff9d4f17456fae97ebbb75fc93 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 24 Oct 2026 14:51:41 -0400 Subject: [PATCH 08/16] fix(core): decode ots proofs with the capture's lenient base64 engine --- crates/nostream-core/src/nip03.rs | 71 ++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/crates/nostream-core/src/nip03.rs b/crates/nostream-core/src/nip03.rs index 3f7aa1100..f3006f0be 100644 --- a/crates/nostream-core/src/nip03.rs +++ b/crates/nostream-core/src/nip03.rs @@ -10,7 +10,8 @@ //! kind-1040 strategy puts them in the event's `OK` frame after `invalid: `. use base64::Engine; -use base64::engine::general_purpose::STANDARD; +use base64::alphabet; +use base64::engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig}; use crate::constants::MAX_SAFE_INTEGER; use crate::event::EventId; @@ -154,6 +155,15 @@ pub fn parse_ots_file(bytes: &[u8]) -> Result { }) } +/// 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`). /// @@ -172,7 +182,7 @@ pub fn validate_ots_proof(base64_content: &str, target_event_id: &EventId) -> Re return Err("content is not valid base64".to_owned()); } - let bytes = STANDARD + let bytes = LENIENT .decode(base64_content) .map_err(|_| "content is not valid base64".to_owned())?; @@ -407,6 +417,7 @@ mod tests { #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use super::*; + use base64::engine::general_purpose::STANDARD; const TARGET: &str = "e71c6ea722987debdb60f81f9ea4f604b5ac0664120dd64fb9d23abc4ec7c323"; @@ -690,4 +701,60 @@ mod tests { 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); + } } From 3c6c91293a2733fa322fd7ac9e6395b618d53317 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 24 Oct 2026 14:51:41 -0400 Subject: [PATCH 09/16] docs: correct the strategies test count in the release note --- .changeset/relay-event-strategies.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/relay-event-strategies.md b/.changeset/relay-event-strategies.md index 426d9b9a6..63708a64b 100644 --- a/.changeset/relay-event-strategies.md +++ b/.changeset/relay-event-strategies.md @@ -19,5 +19,5 @@ 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 99 tests. Decisions are recorded in +target's 100 tests. Decisions are recorded in `docs/rewrite/decisions/0006-event-strategies.md`. From 7d27cddbac763abd6301568020ec43989352aa67 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 24 Oct 2026 14:52:22 -0400 Subject: [PATCH 10/16] refactor(relay): hold the trust graph on the handler context, not the stores --- crates/nostream-relay/src/handlers/mod.rs | 12 ++++++------ crates/nostream-relay/src/relay.rs | 9 +++++++-- .../src/strategies/contact_list.rs | 1 - crates/nostream-relay/src/strategies/report.rs | 2 +- crates/nostream-relay/src/testing.rs | 18 ++++++++++++++---- crates/nostream-relay/src/unavailable.rs | 7 +++---- crates/nostream-relay/src/wot.rs | 5 +++-- crates/nostream-relay/tests/common/mod.rs | 1 + crates/nostream-relay/tests/strategies.rs | 7 ++----- crates/nostream-relay/tests/wire_parity.rs | 1 + crates/nostreamd/src/main.rs | 2 ++ crates/nostreamd/tests/acceptor.rs | 1 + crates/nostreamd/tests/wire.rs | 1 + .../rewrite/decisions/0006-event-strategies.md | 5 ++++- 14 files changed, 46 insertions(+), 26 deletions(-) diff --git a/crates/nostream-relay/src/handlers/mod.rs b/crates/nostream-relay/src/handlers/mod.rs index 8aff4171d..5b6a3cb44 100644 --- a/crates/nostream-relay/src/handlers/mod.rs +++ b/crates/nostream-relay/src/handlers/mod.rs @@ -37,10 +37,9 @@ use crate::relay_key::RelayKeySource; use crate::sink::FrameSink; use crate::wot::WotGraph; -/// The repository and service 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. Cheap to clone: every -/// field is an `Arc`, so a test can copy a context's stores and swap the one seam it is about. -#[derive(Clone)] +/// 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. Storage only: a service the +/// relay owns lives on the context beside `limits`. pub struct Stores { pub events: Arc, pub users: Arc, @@ -48,8 +47,6 @@ pub struct Stores { pub invites: Arc, pub dvm_jobs: Arc, pub reports: Arc, - /// The trust graph `contact_list` and `report` reach for, best-effort. - pub wot: Arc, } /// The context one socket shares across handlers (`src/@types/message-handlers.ts`). @@ -65,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/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 ecf5c2b5a..077989b43 100644 --- a/crates/nostream-relay/src/strategies/contact_list.rs +++ b/crates/nostream-relay/src/strategies/contact_list.rs @@ -66,7 +66,6 @@ impl EventStrategy for ContactListStrategy { .collect(); if let Err(error) = context - .stores .wot .update_follow_list(&event.pubkey, &follows) .await diff --git a/crates/nostream-relay/src/strategies/report.rs b/crates/nostream-relay/src/strategies/report.rs index 053637b0c..fb8f3dc83 100644 --- a/crates/nostream-relay/src/strategies/report.rs +++ b/crates/nostream-relay/src/strategies/report.rs @@ -82,7 +82,7 @@ async fn record_report(event: &Event, context: &HandlerContext) -> Result<(), Re let distance = if is_trusted_moderator { None } else { - context.stores.wot.get_distance(&event.pubkey).await? + context.wot.get_distance(&event.pubkey).await? }; let weight = score_reporter(distance, is_trusted_moderator).weight(); diff --git a/crates/nostream-relay/src/testing.rs b/crates/nostream-relay/src/testing.rs index 0559dc7b2..e4e2a2d13 100644 --- a/crates/nostream-relay/src/testing.rs +++ b/crates/nostream-relay/src/testing.rs @@ -1392,7 +1392,7 @@ impl crate::limits::Limiter for FailingLimiter { } } -/// The six doubles and the trust-graph recorder as one [`Stores`] value. +/// The six doubles as one [`Stores`] value. pub fn stores( events: Arc, users: Arc, @@ -1400,7 +1400,6 @@ pub fn stores( invites: Arc, reports: Arc, dvm: Arc, - wot: Arc, ) -> Arc { Arc::new(Stores { events, @@ -1409,7 +1408,6 @@ pub fn stores( invites, dvm_jobs: dvm, reports, - wot, }) } @@ -1449,7 +1447,6 @@ impl Fixture { Arc::clone(&invites), Arc::clone(&reports), Arc::clone(&dvm), - Arc::clone(&wot), ), events, users, @@ -1463,6 +1460,16 @@ impl Fixture { } } + /// 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 { @@ -1500,6 +1507,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), @@ -1507,6 +1516,7 @@ impl Fixture { sink, connection, stores: Arc::clone(&self.stores), + wot, limits, hidden: Arc::clone(&self.hidden), keys: Arc::new(keys), diff --git a/crates/nostream-relay/src/unavailable.rs b/crates/nostream-relay/src/unavailable.rs index 995efb6c3..0c23895e6 100644 --- a/crates/nostream-relay/src/unavailable.rs +++ b/crates/nostream-relay/src/unavailable.rs @@ -20,8 +20,7 @@ use nostream_storage::repositories::{ use crate::handlers::Stores; use crate::wot::{WotFuture, WotGraph}; -/// The six stores, every call reporting `NotImplemented`, and the trust graph, which has no -/// graph to read. +/// The six stores, every call reporting `NotImplemented`. pub fn stores() -> Stores { Stores { events: std::sync::Arc::new(UnavailableEventRepository), @@ -30,7 +29,6 @@ pub fn stores() -> Stores { invites: std::sync::Arc::new(UnavailableInviteCodeRepository), dvm_jobs: std::sync::Arc::new(UnavailableDvmJobRepository), reports: std::sync::Arc::new(UnavailableReportRepository), - wot: std::sync::Arc::new(UnavailableWotGraph), } } @@ -40,7 +38,8 @@ fn unavailable() -> StorageError { } } -/// The trust graph, for a daemon with no `nostream-services` wiring. "Unavailable" is the +/// 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 diff --git a/crates/nostream-relay/src/wot.rs b/crates/nostream-relay/src/wot.rs index 8f8746802..396056c98 100644 --- a/crates/nostream-relay/src/wot.rs +++ b/crates/nostream-relay/src/wot.rs @@ -7,8 +7,9 @@ //! //! 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. Both callers treat the seam as -//! best-effort, so a daemon whose graph is not wired still serves. +//! [`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; 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/strategies.rs b/crates/nostream-relay/tests/strategies.rs index 0a573da42..83cc663f9 100644 --- a/crates/nostream-relay/tests/strategies.rs +++ b/crates/nostream-relay/tests/strategies.rs @@ -24,7 +24,7 @@ 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, Stores}; +use nostream_relay::handlers::HandlerContext; use nostream_relay::relay_key::RelayKeySource; use nostream_relay::sink::FrameSink; use nostream_relay::strategies::EventStrategy; @@ -1656,10 +1656,7 @@ async fn report_records_zero_weight_outside_the_graph() { 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.stores = Arc::new(Stores { - wot: Arc::new(UnavailableWotGraph), - ..(*context.stores).clone() - }); + 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. diff --git a/crates/nostream-relay/tests/wire_parity.rs b/crates/nostream-relay/tests/wire_parity.rs index 6b37f4e31..b4775de49 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, 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/0006-event-strategies.md b/docs/rewrite/decisions/0006-event-strategies.md index fc0b078e7..bcabedfca 100644 --- a/docs/rewrite/decisions/0006-event-strategies.md +++ b/docs/rewrite/decisions/0006-event-strategies.md @@ -24,7 +24,10 @@ greppable. `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:24` declares `WotGraph` beside `limits::Limiter`, -which is the file's existing precedent for a service the relay needs and does not own. +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: From 24b679c293ea51171ab22c53568eb2c6ae5c99bd Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 24 Oct 2026 14:52:29 -0400 Subject: [PATCH 11/16] refactor(relay): decide tag hex with the core parsers, not a second predicate --- crates/nostream-relay/src/strategies/delete.rs | 17 +++++++++-------- .../nostream-relay/src/strategies/gift_wrap.rs | 6 +++--- crates/nostream-relay/src/strategies/mod.rs | 8 +++++--- .../nostream-relay/src/strategies/timestamp.rs | 12 ++++++------ docs/rewrite/decisions/0006-event-strategies.md | 6 ++++-- 5 files changed, 27 insertions(+), 22 deletions(-) diff --git a/crates/nostream-relay/src/strategies/delete.rs b/crates/nostream-relay/src/strategies/delete.rs index 749f60caa..af6604dab 100644 --- a/crates/nostream-relay/src/strategies/delete.rs +++ b/crates/nostream-relay/src/strategies/delete.rs @@ -1,18 +1,19 @@ //! The `delete` event strategy. Mirrors `src/handlers/event-strategies/delete-event-strategy.ts`. //! -//! A NIP-09 request: every `e` tag whose value is 64 lowercase hex characters is soft-deleted -//! **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. +//! 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, event_record, is_lowercase_hex64}; +use crate::strategies::{EventStrategy, event_record}; /// The `delete` arm. #[derive(Debug, Clone, Copy, Default)] @@ -25,11 +26,11 @@ impl EventStrategy for DeleteStrategy { context: &'a HandlerContext, ) -> Pin> + Send + 'a>> { Box::pin(async move { - let ids: Vec = event + let ids: Vec = event .tags .iter() - .filter(|tag| tag.len() >= 2 && tag[0] == "e" && is_lowercase_hex64(&tag[1])) - .filter_map(|tag| tag[1].parse().ok()) + .filter(|tag| tag.len() >= 2 && tag[0] == "e") + .filter_map(|tag| tag[1].parse::().ok()) .collect(); if !ids.is_empty() { diff --git a/crates/nostream-relay/src/strategies/gift_wrap.rs b/crates/nostream-relay/src/strategies/gift_wrap.rs index 84a2513ca..f3fa2d65a 100644 --- a/crates/nostream-relay/src/strategies/gift_wrap.rs +++ b/crates/nostream-relay/src/strategies/gift_wrap.rs @@ -10,12 +10,12 @@ 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, event_record, is_lowercase_hex64}; +use crate::strategies::{EventStrategy, event_record}; /// The `gift_wrap` arm. #[derive(Debug, Clone, Copy, Default)] @@ -72,7 +72,7 @@ fn validate_gift_wrap(event: &Event) -> Option { return Some("gift wrap event (kind 1059) must have exactly one p tag".to_owned()); } - if !is_lowercase_hex64(&recipients[0][1]) { + 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(), diff --git a/crates/nostream-relay/src/strategies/mod.rs b/crates/nostream-relay/src/strategies/mod.rs index d3b1b0975..3e7a43c63 100644 --- a/crates/nostream-relay/src/strategies/mod.rs +++ b/crates/nostream-relay/src/strategies/mod.rs @@ -177,9 +177,11 @@ fn event_record(event: &Event, context: &HandlerContext) -> EventRecord { record } -/// Whether a tag value is exactly 32 bytes of lowercase hex — the rule the `delete`, -/// `gift_wrap`, `group` and `timestamp` arms apply to theirs. The report arm deliberately -/// accepts either case (`nip56::extract_report_targets`); ADR 0006 records why. +/// 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 diff --git a/crates/nostream-relay/src/strategies/timestamp.rs b/crates/nostream-relay/src/strategies/timestamp.rs index 2a9161f5b..71df66c3a 100644 --- a/crates/nostream-relay/src/strategies/timestamp.rs +++ b/crates/nostream-relay/src/strategies/timestamp.rs @@ -12,12 +12,12 @@ 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, event_record, is_lowercase_hex64}; +use crate::strategies::{EventStrategy, event_record}; /// The `timestamp` arm. #[derive(Debug, Clone, Copy, Default)] @@ -80,11 +80,13 @@ fn validate(event: &Event) -> Option { let target = &attested[0][1]; - if !is_lowercase_hex64(target) { + // `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 @@ -102,8 +104,6 @@ fn validate(event: &Event) -> Option { return Some("opentimestamps k tag must be a non-negative integer kind".to_owned()); } - let target_event_id = target.parse().ok()?; - validate_ots_proof(&event.content, &target_event_id).err() } diff --git a/docs/rewrite/decisions/0006-event-strategies.md b/docs/rewrite/decisions/0006-event-strategies.md index bcabedfca..b8927b5de 100644 --- a/docs/rewrite/decisions/0006-event-strategies.md +++ b/docs/rewrite/decisions/0006-event-strategies.md @@ -111,8 +111,10 @@ accepts uppercase hex. The four validators that refuse uppercase are lowercase-o `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 share -`strategies::is_lowercase_hex64` (`src/strategies/mod.rs:183`). +(`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 From 2a428c2836b5ea1a0c855c478744ca78dc60dc9a Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 24 Oct 2026 15:23:39 -0400 Subject: [PATCH 12/16] test(relay): tolerate a lost write race on the oversized-frame step The relay refuses an oversized frame from its declared length, so it can close before the client finishes writing 614 600 bytes. The client's send then fails with Io(BrokenPipe) and the test aborted there, never comparing the close frame it exists to assert: 10 failures in 20 release runs on an idle box. Tolerate an IO error on that send and keep the 1009 comparison as the assertion. A non-IO send failure still fails. Measured after the fix: 0 failures in 30 runs, with the write losing the race in 11 of 20 during classification. Also correct four line citations in ADR 0006 that round 2's inserts moved. --- crates/nostream-relay/tests/wire_parity.rs | 17 +++++++++++++---- docs/rewrite/decisions/0006-event-strategies.md | 8 ++++---- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/crates/nostream-relay/tests/wire_parity.rs b/crates/nostream-relay/tests/wire_parity.rs index b4775de49..ed9a699b8 100644 --- a/crates/nostream-relay/tests/wire_parity.rs +++ b/crates/nostream-relay/tests/wire_parity.rs @@ -490,10 +490,19 @@ async fn replay(session: &Session, random: Arc) -> BTreeMap`, not `[u8; 32]`** (`src/nip03.rs:110`), which departs +**`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:191`), so the sha256 rule stays where the capture puts it. +(`src/nip03.rs:199`), so the sha256 rule stays where the capture puts it. ## A report's weight is a method on the enum From 0dac5fe33cf6b678b923698d575c2767c228dc8b Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 24 Oct 2026 15:33:36 -0400 Subject: [PATCH 13/16] fix(relay): let a shared invite code admit a second pubkey in the double --- crates/nostream-relay/src/testing.rs | 108 ++++++++++++++++++++++++--- 1 file changed, 97 insertions(+), 11 deletions(-) diff --git a/crates/nostream-relay/src/testing.rs b/crates/nostream-relay/src/testing.rs index e4e2a2d13..6a17ca304 100644 --- a/crates/nostream-relay/src/testing.rs +++ b/crates/nostream-relay/src/testing.rs @@ -933,8 +933,12 @@ impl InviteCodeRepository for InMemoryInviteCodeRepository { 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), - FieldUpdate::Absent | FieldUpdate::Clear => None, }, }; let invite = row.code(code); @@ -966,8 +970,12 @@ impl InviteCodeRepository for InMemoryInviteCodeRepository { }) } - /// One use of the code, refused when it is absent, expired, claimed by someone else, or - /// out of uses (`claimCode`, `src/repositories/invite-code-repository.ts`). + /// 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 @@ -989,13 +997,6 @@ impl InviteCodeRepository for InMemoryInviteCodeRepository { return Ok(false); }; - if row - .claimed_by - .is_some_and(|claimed_by| claimed_by != *pubkey) - { - return Ok(false); - } - if row .options .expires_at @@ -1012,7 +1013,7 @@ impl InviteCodeRepository for InMemoryInviteCodeRepository { row.remaining_uses = Some(remaining - 1); } - row.claimed_by = Some(*pubkey); + row.claimed_by = row.claimed_by.or(Some(*pubkey)); Ok(true) }) @@ -1570,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)); + } +} From ae6f889a1ccc04593a5973dc154b4203581e6694 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 24 Oct 2026 15:58:58 -0400 Subject: [PATCH 14/16] docs: point ADR 0006's two remaining citations at the lines they name The DuplicateEvent variant this branch added moved the storage crate's own doc on forwarding a driver's message to a caller; the citation still named the old span. The UnavailableWotGraph item ends at the impl's closing brace, not one line past it. --- docs/rewrite/decisions/0006-event-strategies.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/rewrite/decisions/0006-event-strategies.md b/docs/rewrite/decisions/0006-event-strategies.md index 4b166071a..188c15a50 100644 --- a/docs/rewrite/decisions/0006-event-strategies.md +++ b/docs/rewrite/decisions/0006-event-strategies.md @@ -38,7 +38,7 @@ The shapes rejected: 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-69`) is the capture's *disabled* graph, not a +`UnavailableWotGraph` (`src/unavailable.rs:41-66`) 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 @@ -93,7 +93,7 @@ census case reaches it. (`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:12-15`). +(`crates/nostream-storage/src/error.rs:20-23`). 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 From 06463a57772be51062779b342069ced7c2760e15 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 24 Oct 2026 16:04:25 -0400 Subject: [PATCH 15/16] docs: pin ADR 0006's two citations to the exact spans the approver named The rule against forwarding a driver's message is stated in the first two lines of the doc block, and the UnavailableWotGraph impl closes one line past the span cited at the previous commit. --- docs/rewrite/decisions/0006-event-strategies.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/rewrite/decisions/0006-event-strategies.md b/docs/rewrite/decisions/0006-event-strategies.md index 188c15a50..f84d6526d 100644 --- a/docs/rewrite/decisions/0006-event-strategies.md +++ b/docs/rewrite/decisions/0006-event-strategies.md @@ -38,7 +38,7 @@ The shapes rejected: 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-66`) is the capture's *disabled* graph, not a +`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 @@ -93,7 +93,7 @@ census case reaches it. (`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-23`). +(`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 From ba95e5202c5dcf92d6aee9a18de1821a82c0f38d Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 24 Oct 2026 20:24:06 +0000 Subject: [PATCH 16/16] test(core): give the report-target fixture a letter so uppercase means something `pubkey_hex()` returned `"3".repeat(64)`, so `to_uppercase()` was the identity and `uppercase_hex_names_the_same_target` asserted that parsing a string equals parsing the same string. Greptile P2 on #976. The fixture is now hex with letters in it, and the test guards itself with an assert_ne so an all-digit fixture fails loudly instead of passing vacuously. Mutation-checked: restoring `"3".repeat(64)` fails with the guard's message. Same for the event-id fixture, which had no uppercase test but the same shape. --- crates/nostream-core/src/nip56.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/nostream-core/src/nip56.rs b/crates/nostream-core/src/nip56.rs index aa5951571..7a0cbfeac 100644 --- a/crates/nostream-core/src/nip56.rs +++ b/crates/nostream-core/src/nip56.rs @@ -179,12 +179,14 @@ mod tests { 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 { - "3".repeat(64) + "3a".repeat(32) } fn event_id_hex() -> String { - "4".repeat(64) + "4c".repeat(32) } fn tag(parts: &[&str]) -> Tag { @@ -276,7 +278,14 @@ mod tests { #[test] fn uppercase_hex_names_the_same_target() { let pubkey = pubkey_hex(); - let targets = extract_report_targets(&[tag(&["p", &pubkey.to_uppercase()])]); + 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())); }