From 3384881b9ab092c05913cb985491ea134592cb38 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 15:22:10 -0400 Subject: [PATCH 01/32] chore(rust): scaffold the cargo workspace, pin the 1.99.1 toolchain and add the sandbox runner Root [workspace] with resolver 3, the workspace dependency table, the workspace lint table and the rust-toolchain.toml pin (1.99.1, rustfmt+clippy, minimal profile). scripts/rust.sh runs one cargo command inside containers-rust-builder with the host caches, create -> start -> wait -> logs -> rm, cleaning up on every exit path. --- Cargo.lock | 360 ++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 46 ++++++ rust-toolchain.toml | 4 + scripts/rust.sh | 72 +++++++++ 4 files changed, 482 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 rust-toolchain.toml create mode 100755 scripts/rust.sh diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 000000000..2ffe9ac18 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,360 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nostream-config" +version = "0.1.0" +dependencies = [ + "nostream-core", + "serde", + "serde_json", + "serde_yaml", + "tempfile", + "thiserror", +] + +[[package]] +name = "nostream-core" +version = "0.1.0" +dependencies = [ + "serde", + "thiserror", + "tokio", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 000000000..5734b5fd7 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,46 @@ +[workspace] +members = ["crates/*"] +resolver = "3" + +[workspace.package] +version = "0.1.0" +edition = "2024" +rust-version = "1.99" +license = "MIT" +repository = "https://github.com/cameri/nostream" + +[workspace.dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive"] } +heed = "0.20" +nostream-admin = { path = "crates/nostream-admin" } +nostream-cli = { path = "crates/nostream-cli" } +nostream-config = { path = "crates/nostream-config" } +nostream-core = { path = "crates/nostream-core" } +nostream-relay = { path = "crates/nostream-relay" } +nostream-services = { path = "crates/nostream-services" } +nostream-storage = { path = "crates/nostream-storage" } +nostreamd = { path = "crates/nostreamd" } +opentelemetry = "0.33.1" +opentelemetry-otlp = { version = "0.33.1", default-features = false, features = ["trace", "grpc-tonic"] } +opentelemetry_sdk = { version = "0.33.1", default-features = false, features = ["trace", "rt-tokio"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +tempfile = "3" +thiserror = "2" +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] } +tracing = "0.1" +tracing-opentelemetry = "0.34" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } + +[workspace.lints.rust] +# `deny` rather than `forbid`: heed's LMDB `EnvOpenOptions::open` is an `unsafe fn` +# (heed 0.20.5, src/env.rs:236) and the storage crate wraps it in exactly one +# audited `#[allow(unsafe_code)]` call. Every other crate is effect-free unsafe. +unsafe_code = "deny" + +[workspace.lints.clippy] +todo = "deny" +unimplemented = "deny" +dbg_macro = "deny" diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 000000000..955e96455 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.99.1" +components = ["rustfmt", "clippy"] +profile = "minimal" diff --git a/scripts/rust.sh b/scripts/rust.sh new file mode 100755 index 000000000..0d0316826 --- /dev/null +++ b/scripts/rust.sh @@ -0,0 +1,72 @@ +#!/bin/sh +# Run one cargo/rustc command in the Rust builder container. +# +# This sandbox has no Rust toolchain and its Docker policy denies attach/exec for +# non-builder containers, so the command runs as create -> start -> wait -> logs -> rm, +# exactly the shape containers/rust-builder/rb-run uses. Nothing of our own is written to +# stdout: cargo's output is the only output. +# +# Usage: +# scripts/rust.sh cargo test --workspace +# scripts/rust.sh cargo fmt --all --check +# scripts/rust.sh rustc --version +# +# Environment: +# NOSTREAM_HOST_DIR source tree as the Docker daemon sees it. Default: this script's +# repository root, translated /workspace -> /home/cameri/Workspace. +# RUST_CACHE_HOST host cache root (default /home/cameri/Workspace/containers/rust-builder/cache) +# RUST_BUILDER_IMAGE image (default containers-rust-builder:latest) +set -eu + +if [ "$#" -eq 0 ]; then + echo "usage: scripts/rust.sh [args...]" >&2 + exit 64 +fi + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +local_root=$(CDPATH= cd -- "$script_dir/.." && pwd) +case "$local_root" in + /workspace/*) default_root="/home/cameri/Workspace/${local_root#/workspace/}" ;; + *) default_root="$local_root" ;; +esac + +src_host=${NOSTREAM_HOST_DIR:-$default_root} +cache_host=${RUST_CACHE_HOST:-/home/cameri/Workspace/containers/rust-builder/cache} +image=${RUST_BUILDER_IMAGE:-containers-rust-builder:latest} + +# The daemon silently creates a missing bind source as an empty directory, which then looks +# like a tree without a manifest. Check the tree from the side we can see before creating the +# container: the local path when the script runs in the sandbox, the host path when it runs +# on the host (where NOSTREAM_HOST_DIR is normally unset). +if [ -n "${NOSTREAM_HOST_DIR:-}" ]; then + checked_root=$src_host +else + checked_root=$local_root +fi +if [ ! -f "$checked_root/Cargo.toml" ]; then + echo "scripts/rust.sh: no Cargo.toml at $checked_root (set NOSTREAM_HOST_DIR)" >&2 + exit 66 +fi + +name="rust-run-$$-$(date +%s)" +cleanup() { + docker rm -f "$name" >/dev/null 2>&1 || true +} +trap cleanup EXIT INT HUP TERM + +docker create --name "$name" --label org.testcontainers=true --user 1000:1000 \ + -e CARGO_HOME=/home/node/.cargo \ + -e RUSTUP_HOME=/home/node/.rustup \ + -e CARGO_TARGET_DIR=/target \ + -e CARGO_TERM_COLOR=never \ + -e HOME=/home/node \ + -v "$cache_host/cargo:/home/node/.cargo" \ + -v "$cache_host/rustup:/home/node/.rustup" \ + -v "$cache_host/target:/target" \ + -v "$src_host:/work/nostream" \ + "$image" bash -lc 'cd /work/nostream && exec "$@"' rust-sh "$@" >/dev/null + +docker start "$name" >/dev/null +status=$(docker wait "$name") +docker logs "$name" 2>&1 +exit "$status" From 519d476576586ed172c34c73431f00552049b2c5 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 15:22:19 -0400 Subject: [PATCH 02/32] feat(core): carry the domain type surface with labelled stubs Event/EventId/Pubkey/Kind/KindSelector, Filter, the client and relay message payloads, EventStoreBackend (postgres + lmdb), the ShutdownState drain flag, and one module per nip*.ts utility from the capture. Every entry point that would need real behaviour returns CoreError::NotImplemented; there is no crypto dependency yet (the secp256k1 vs k256 choice is a Phase 2 decision). --- crates/nostream-core/Cargo.toml | 16 ++++ crates/nostream-core/src/adaptive_pow.rs | 24 +++++ crates/nostream-core/src/constants.rs | 10 ++ crates/nostream-core/src/crypto.rs | 25 +++++ crates/nostream-core/src/error.rs | 19 ++++ crates/nostream-core/src/event.rs | 59 ++++++++++++ crates/nostream-core/src/event_store.rs | 40 ++++++++ crates/nostream-core/src/filter.rs | 33 +++++++ crates/nostream-core/src/geohash.rs | 21 +++++ crates/nostream-core/src/lib.rs | 67 +++++++++++++ crates/nostream-core/src/lifecycle.rs | 47 +++++++++ crates/nostream-core/src/messages.rs | 105 +++++++++++++++++++++ crates/nostream-core/src/negentropy.rs | 20 ++++ crates/nostream-core/src/nip03.rs | 13 +++ crates/nostream-core/src/nip05.rs | 22 +++++ crates/nostream-core/src/nip25.rs | 18 ++++ crates/nostream-core/src/nip42.rs | 31 ++++++ crates/nostream-core/src/nip43.rs | 28 ++++++ crates/nostream-core/src/nip44.rs | 21 +++++ crates/nostream-core/src/nip56.rs | 23 +++++ crates/nostream-core/src/nip65.rs | 21 +++++ crates/nostream-core/src/nip66.rs | 28 ++++++ crates/nostream-core/src/nip77.rs | 23 +++++ crates/nostream-core/src/nip98.rs | 24 +++++ crates/nostream-core/src/pow.rs | 13 +++ crates/nostream-core/src/report_scoring.rs | 21 +++++ crates/nostream-core/src/secret.rs | 27 ++++++ crates/nostream-core/src/validation.rs | 15 +++ 28 files changed, 814 insertions(+) create mode 100644 crates/nostream-core/Cargo.toml create mode 100644 crates/nostream-core/src/adaptive_pow.rs create mode 100644 crates/nostream-core/src/constants.rs create mode 100644 crates/nostream-core/src/crypto.rs create mode 100644 crates/nostream-core/src/error.rs create mode 100644 crates/nostream-core/src/event.rs create mode 100644 crates/nostream-core/src/event_store.rs create mode 100644 crates/nostream-core/src/filter.rs create mode 100644 crates/nostream-core/src/geohash.rs create mode 100644 crates/nostream-core/src/lib.rs create mode 100644 crates/nostream-core/src/lifecycle.rs create mode 100644 crates/nostream-core/src/messages.rs create mode 100644 crates/nostream-core/src/negentropy.rs create mode 100644 crates/nostream-core/src/nip03.rs create mode 100644 crates/nostream-core/src/nip05.rs create mode 100644 crates/nostream-core/src/nip25.rs create mode 100644 crates/nostream-core/src/nip42.rs create mode 100644 crates/nostream-core/src/nip43.rs create mode 100644 crates/nostream-core/src/nip44.rs create mode 100644 crates/nostream-core/src/nip56.rs create mode 100644 crates/nostream-core/src/nip65.rs create mode 100644 crates/nostream-core/src/nip66.rs create mode 100644 crates/nostream-core/src/nip77.rs create mode 100644 crates/nostream-core/src/nip98.rs create mode 100644 crates/nostream-core/src/pow.rs create mode 100644 crates/nostream-core/src/report_scoring.rs create mode 100644 crates/nostream-core/src/secret.rs create mode 100644 crates/nostream-core/src/validation.rs diff --git a/crates/nostream-core/Cargo.toml b/crates/nostream-core/Cargo.toml new file mode 100644 index 000000000..f54325069 --- /dev/null +++ b/crates/nostream-core/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "nostream-core" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Nostr domain types, wire messages and NIP surfaces for the nostream rewrite" + +[dependencies] +serde.workspace = true +thiserror.workspace = true +tokio.workspace = true + +[lints] +workspace = true diff --git a/crates/nostream-core/src/adaptive_pow.rs b/crates/nostream-core/src/adaptive_pow.rs new file mode 100644 index 000000000..0d5d67800 --- /dev/null +++ b/crates/nostream-core/src/adaptive_pow.rs @@ -0,0 +1,24 @@ +//! Load-aware PoW difficulty. Mirrors `src/utils/adaptive-pow.ts`. + +/// The adaptive-difficulty knobs, copied from the settings section of the same name. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AdaptivePowConfig { + pub enabled: bool, + pub floor_bits: u32, + pub ceiling_bits: u32, + pub target_events_per_second: f64, + pub period_ms: u64, +} + +/// The required difficulty for the observed event rate. +/// +/// Phase 1 stub — job 005; will scale difficulty between `floor_bits` and `ceiling_bits` +/// from the EWMA event rate; see `src/utils/adaptive-pow.ts`. +pub fn compute_difficulty( + _config: &AdaptivePowConfig, + _observed_events_per_second: f64, +) -> Result { + Err(crate::error::CoreError::NotImplemented { + module: "core::adaptive_pow::compute_difficulty", + }) +} diff --git a/crates/nostream-core/src/constants.rs b/crates/nostream-core/src/constants.rs new file mode 100644 index 000000000..c83ed3d35 --- /dev/null +++ b/crates/nostream-core/src/constants.rs @@ -0,0 +1,10 @@ +//! Protocol constants. Mirrors `src/constants/base.ts` (the subset the domain types need). + +/// The largest integer JavaScript represents exactly; used by the settings defaults. +pub const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; + +/// The NIP-11 `supported_nips` advertisement (`src/constants/base.ts` `SUPPORTED_NIPS` shape). +pub const SUPPORTED_NIPS: [u16; 4] = [11, 42, 45, 50]; + +/// The payment processors the settings surface names (`src/constants/base.ts` `PaymentsProcessors`). +pub const PAYMENTS_PROCESSORS: [&str; 6] = ["lnurl", "zebedee", "lnbits", "nodeless", "opennode", "nwc"]; diff --git a/crates/nostream-core/src/crypto.rs b/crates/nostream-core/src/crypto.rs new file mode 100644 index 000000000..22ee480c2 --- /dev/null +++ b/crates/nostream-core/src/crypto.rs @@ -0,0 +1,25 @@ +//! Event signing and hashing. Mirrors `src/utils/event.ts` (`verifyEvent`, `getEventHash`). +//! +//! Phase 1 carries no crypto dependency on purpose: `secp256k1` versus `k256` and schnorr +//! support is a Phase 2 security decision, recorded in the job's design notes. + +use crate::error::CoreError; +use crate::event::{Event, EventId}; + +/// Verifies the Schnorr signature over the canonical serialization. +/// +/// Phase 1 stub — job 005; will verify a Schnorr signature over the canonical event id; see `src/utils/event.ts`. +pub fn verify_event_signature(_event: &Event) -> Result<(), CoreError> { + Err(CoreError::NotImplemented { + module: "core::crypto::verify_event_signature", + }) +} + +/// Computes an event id (sha256 over `[0, pubkey, created_at, kind, tags, content]`). +/// +/// Phase 1 stub — job 005; will hash the canonical serialization into an `EventId`; see `src/utils/event.ts`. +pub fn compute_event_id(_event: &Event) -> Result { + Err(CoreError::NotImplemented { + module: "core::crypto::compute_event_id", + }) +} diff --git a/crates/nostream-core/src/error.rs b/crates/nostream-core/src/error.rs new file mode 100644 index 000000000..84ce18bba --- /dev/null +++ b/crates/nostream-core/src/error.rs @@ -0,0 +1,19 @@ +//! The crate's error type. Every library crate in this workspace names its own. + +use thiserror::Error; + +/// Errors raised by `nostream-core`. +#[derive(Debug, Error)] +pub enum CoreError { + /// A Phase 1 stub. The module names the call that is not implemented yet. + #[error("not implemented in Phase 1 (job 005): {module}")] + NotImplemented { module: &'static str }, + + /// The event violates the base schema (id/sig length, kind range, tag shape). + #[error("invalid event: {0}")] + InvalidEvent(String), + + /// A wire frame could not be decoded into a known message. + #[error("invalid message: {0}")] + InvalidMessage(String), +} diff --git a/crates/nostream-core/src/event.rs b/crates/nostream-core/src/event.rs new file mode 100644 index 000000000..e27d9b471 --- /dev/null +++ b/crates/nostream-core/src/event.rs @@ -0,0 +1,59 @@ +//! Shared domain primitives. Mirrors `src/@types/event.ts`, `src/@types/base.ts`, +//! `src/@types/subscription.ts` and `src/constants/base.ts` at the capture commit. + +use serde::{Deserialize, Serialize}; + +/// A 32-byte event id, hex encoded (`src/@types/base.ts` `EventId`). +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct EventId(pub String); + +/// A 32-byte public key, hex encoded (`src/@types/base.ts` `Pubkey`). +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Pubkey(pub String); + +/// A Nostr event kind (`src/constants/base.ts` `EventKinds`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Kind(pub u64); + +impl Kind { + pub const METADATA: Self = Self(0); + pub const CONTACT_LIST: Self = Self(3); + pub const DELETION: Self = Self(5); + pub const REPORT: Self = Self(1984); + pub const EPHEMERAL_START: Self = Self(20_000); + pub const EPHEMERAL_END: Self = Self(29_999); + pub const PARAMETERIZED_REPLACEABLE_START: Self = Self(30_000); + pub const PARAMETERIZED_REPLACEABLE_END: Self = Self(39_999); +} + +impl std::fmt::Display for Kind { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}", self.0) + } +} + +/// One kind or an inclusive `[first, last]` range (`src/@types/settings.ts` `EventKindsRange`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum KindSelector { + Range([Kind; 2]), + Single(Kind), +} + +/// A tag: the wire shape is a positional string array. +pub type Tag = Vec; + +/// A Nostr event (`src/@types/event.ts`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Event { + pub id: EventId, + pub pubkey: Pubkey, + pub created_at: i64, + pub kind: Kind, + pub tags: Vec, + pub content: String, + pub sig: String, +} diff --git a/crates/nostream-core/src/event_store.rs b/crates/nostream-core/src/event_store.rs new file mode 100644 index 000000000..d245d8de0 --- /dev/null +++ b/crates/nostream-core/src/event_store.rs @@ -0,0 +1,40 @@ +//! The event-store backend set. Mirrors `src/constants/base.ts` `EventStoreBackend` and +//! `src/utils/event-store.ts` `EVENT_STORE_BACKENDS`. +//! +//! The capture's enum has one member (`POSTGRES`). The rewrite adds `LMDB`, which is the +//! backend this rewrite exists to provide; the config validator uses this list as the set +//! of acceptable `eventStore.backend` values (spec D7). + +use serde::{Deserialize, Serialize}; + +/// The storage backend selected at startup (`src/constants/base.ts` `EventStoreBackend`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum EventStoreBackend { + Postgres, + Lmdb, +} + +impl EventStoreBackend { + /// Every member, in the order the validator renders them. + pub const ALL: [Self; 2] = [Self::Postgres, Self::Lmdb]; + + /// The wire/settings spelling of this backend. + pub const fn as_str(self) -> &'static str { + match self { + Self::Postgres => "postgres", + Self::Lmdb => "lmdb", + } + } + + /// Parses a settings value. A non-string or unknown value is `None`. + pub fn parse(value: &str) -> Option { + Self::ALL.into_iter().find(|backend| backend.as_str() == value) + } +} + +impl std::fmt::Display for EventStoreBackend { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.as_str()) + } +} diff --git a/crates/nostream-core/src/filter.rs b/crates/nostream-core/src/filter.rs new file mode 100644 index 000000000..650f45cfe --- /dev/null +++ b/crates/nostream-core/src/filter.rs @@ -0,0 +1,33 @@ +//! Subscription filters. Mirrors `src/@types/subscription.ts` and `src/utils/filter.ts`. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::event::{EventId, KindSelector, Pubkey}; + +/// A subscription id (`src/@types/subscription.ts` `SubscriptionId`). +pub type SubscriptionId = String; + +/// A Nostr REQ filter (`src/@types/subscription.ts` `SubscriptionFilter`). +/// +/// Single-letter tag filters (`#e`, `#p`, …) are carried in `tags`, keyed without the `#`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Filter { + #[serde(skip_serializing_if = "Option::is_none")] + pub ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub kinds: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub since: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub until: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub authors: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub search: Option, + #[serde(flatten)] + pub tags: BTreeMap>, +} diff --git a/crates/nostream-core/src/geohash.rs b/crates/nostream-core/src/geohash.rs new file mode 100644 index 000000000..bb1c90a5c --- /dev/null +++ b/crates/nostream-core/src/geohash.rs @@ -0,0 +1,21 @@ +//! Geohash helpers. Mirrors `src/utils/geohash.ts`. + +use crate::error::CoreError; + +/// Encodes coordinates at the requested precision. +/// +/// Phase 1 stub — job 005; will encode latitude/longitude into a geohash; see `src/utils/geohash.ts`. +pub fn encode(_latitude: f64, _longitude: f64, _precision: usize) -> Result { + Err(CoreError::NotImplemented { + module: "core::geohash::encode", + }) +} + +/// The set of geohash cells covering a radius, for NIP-52 queries. +/// +/// Phase 1 stub — job 005; will expand a prefix into covering cells; see `src/utils/geohash.ts`. +pub fn covering_cells(_center: &str, _radius_km: f64) -> Result, CoreError> { + Err(CoreError::NotImplemented { + module: "core::geohash::covering_cells", + }) +} diff --git a/crates/nostream-core/src/lib.rs b/crates/nostream-core/src/lib.rs new file mode 100644 index 000000000..1c8c614e1 --- /dev/null +++ b/crates/nostream-core/src/lib.rs @@ -0,0 +1,67 @@ +//! `nostream-core` owns the relay's domain vocabulary: Nostr events, filters and kinds, +//! the client/relay wire messages, shared primitives, and the NIP type surfaces. +//! +//! It owns these TypeScript paths from the capture commit +//! (`242a3001d77149e4d70151ec03060c4ef44a1547`): +//! +//! - `src/constants/base.ts` +//! - `src/schemas/base-schema.ts`, `src/schemas/event-schema.ts`, +//! `src/schemas/filter-schema.ts`, `src/schemas/message-schema.ts` +//! - `src/@types/base.ts`, `src/@types/event.ts`, `src/@types/messages.ts`, +//! `src/@types/utils.ts` +//! - `src/utils/adaptive-pow.ts`, `src/utils/compression.ts`, `src/utils/event.ts`, +//! `src/utils/filter.ts`, `src/utils/geohash.ts`, `src/utils/html.ts`, +//! `src/utils/http.ts`, `src/utils/messages.ts`, `src/utils/misc.ts`, +//! `src/utils/negentropy.ts`, `src/utils/negentropy-sessions.ts`, `src/utils/nip03.ts`, +//! `src/utils/nip05.ts`, `src/utils/nip25.ts`, `src/utils/nip42.ts`, +//! `src/utils/nip42-session.ts`, `src/utils/nip43.ts`, `src/utils/nip43-invites.ts`, +//! `src/utils/nip44.ts`, `src/utils/nip56.ts`, `src/utils/nip65.ts`, +//! `src/utils/nip66-events.ts`, `src/utils/nip66-nip11-tags.ts`, +//! `src/utils/nip66-schedule.ts`, `src/utils/nip77.ts`, `src/utils/nip98.ts`, +//! `src/utils/nip98-replay.ts`, `src/utils/proof-of-work.ts`, +//! `src/utils/report-scoring.ts`, `src/utils/secret.ts`, `src/utils/shutdown-state.ts`, +//! `src/utils/stream.ts`, `src/utils/template-cache.ts`, `src/utils/transform.ts`, +//! `src/utils/validation.ts`, `src/utils/wot-pow-policy.ts` +//! +//! Phase 1 (job 005) carries the type surfaces and labelled stubs only. Every function that +//! needs real behaviour returns `CoreError::NotImplemented`; nothing here is a partial port. +//! +//! Phase 2+ areas this crate deliberately does not own: signature verification and event-id +//! hashing stay stubs until the crypto dependency choice (`secp256k1` vs `k256`, schnorr) is +//! recorded; the negentropy protocol engine and the NIP-77 wire state machine belong to +//! `nostream-relay` once a real transport exists. + +pub mod adaptive_pow; +pub mod constants; +pub mod crypto; +pub mod error; +pub mod event; +pub mod event_store; +pub mod filter; +pub mod geohash; +pub mod lifecycle; +pub mod messages; +pub mod negentropy; +pub mod nip03; +pub mod nip05; +pub mod nip25; +pub mod nip42; +pub mod nip43; +pub mod nip44; +pub mod nip56; +pub mod nip65; +pub mod nip66; +pub mod nip77; +pub mod nip98; +pub mod pow; +pub mod report_scoring; +pub mod secret; +pub mod validation; + +pub use error::CoreError; +pub use event::{Event, EventId, Kind, KindSelector, Pubkey}; +pub use event_store::EventStoreBackend; +pub use filter::Filter; +pub use lifecycle::ShutdownState; +pub use secret::Secret; +pub use messages::{ClientMessage, RelayMessage}; diff --git a/crates/nostream-core/src/lifecycle.rs b/crates/nostream-core/src/lifecycle.rs new file mode 100644 index 000000000..12f8d259a --- /dev/null +++ b/crates/nostream-core/src/lifecycle.rs @@ -0,0 +1,47 @@ +//! Process lifecycle: the shared drain flag. Mirrors `src/utils/shutdown-state.ts`. +//! +//! One `ShutdownState` is created by the composition root and cloned into the acceptor and +//! any future connection handler. `begin_draining` is idempotent; `wait_for_drain` resolves +//! as soon as the flag is set and never misses a drain that starts while it waits. + +use tokio::sync::watch; + +/// The shared graceful-drain flag (`src/utils/shutdown-state.ts` `beginDraining`/`isDraining`). +#[derive(Debug, Clone)] +pub struct ShutdownState { + sender: watch::Sender, +} + +impl Default for ShutdownState { + fn default() -> Self { + Self::new() + } +} + +impl ShutdownState { + /// Creates a state that is not draining. + pub fn new() -> Self { + let (sender, _receiver) = watch::channel(false); + Self { sender } + } + + /// Marks the process as draining. Safe to call more than once. + pub fn begin_draining(&self) { + let _ = self.sender.send(true); + } + + /// Whether draining has begun. + pub fn is_draining(&self) -> bool { + *self.sender.borrow() + } + + /// Resolves once `begin_draining` has been called (immediately if it already has). + pub async fn wait_for_drain(&self) { + let mut receiver = self.sender.subscribe(); + while !*receiver.borrow_and_update() { + if receiver.changed().await.is_err() { + return; + } + } + } +} diff --git a/crates/nostream-core/src/messages.rs b/crates/nostream-core/src/messages.rs new file mode 100644 index 000000000..acfe1fcde --- /dev/null +++ b/crates/nostream-core/src/messages.rs @@ -0,0 +1,105 @@ +//! Client and relay wire messages. Mirrors `src/@types/messages.ts`. +//! +//! Phase 1 carries the typed payloads and the message-kind enum. Decoding a wire frame +//! (a positional array whose first element is the kind) is a Phase 2 concern: the parser +//! is a labelled stub, and no transport exists yet. + +use serde::{Deserialize, Serialize}; + +use crate::event::{Event, EventId, Pubkey}; +use crate::filter::{Filter, SubscriptionId}; + +/// The wire message kinds (`src/@types/messages.ts` `MessageType`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum MessageType { + Req, + Event, + Close, + Notice, + Eose, + Ok, + Count, + Closed, + Auth, +} + +/// `["REQ", , ]` (`src/@types/messages.ts` `SubscribeMessage`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SubscribeMessage { + pub subscription_id: SubscriptionId, + pub filters: Vec, +} + +/// `["COUNT", , ]` (`src/@types/messages.ts` `CountMessage`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CountMessage { + pub subscription_id: SubscriptionId, + pub filters: Vec, +} + +/// `["CLOSE", ]` (`src/@types/messages.ts` `UnsubscribeMessage`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UnsubscribeMessage { + pub subscription_id: SubscriptionId, +} + +/// `["AUTH", ]` (`src/@types/messages.ts` `AuthMessage`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthMessage { + pub event: Event, +} + +/// `["NOTICE", ]` (`src/@types/messages.ts` `NoticeMessage`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NoticeMessage { + pub message: String, +} + +/// `["OK", , , ]` (`src/@types/messages.ts` `CommandResult`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommandResult { + pub event_id: EventId, + pub accepted: bool, + pub message: String, +} + +/// `["CLOSED", , ]` (`src/@types/messages.ts` `ClosedMessage`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClosedMessage { + pub subscription_id: SubscriptionId, + pub message: String, +} + +/// A message a client sends (`src/@types/messages.ts` `IncomingMessage`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ClientMessage { + Event(Event), + Subscribe(SubscribeMessage), + Count(CountMessage), + Close(UnsubscribeMessage), + Auth(AuthMessage), +} + +/// A message the relay sends (`src/@types/messages.ts` `OutgoingMessage`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum RelayMessage { + Notice(NoticeMessage), + Ok(CommandResult), + Closed(ClosedMessage), +} + +/// The NIP-42 challenge a relay sends on connect (`src/@types/messages.ts` `AuthChallengeMessage`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthChallengeMessage { + pub challenge: String, +} + +/// A relay-signed event published by another relay (`src/@types/messages.ts` `IncomingRelayedEventMessage`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RelayedEvent { + pub event: Event, + pub source: Pubkey, +} diff --git a/crates/nostream-core/src/negentropy.rs b/crates/nostream-core/src/negentropy.rs new file mode 100644 index 000000000..116484c25 --- /dev/null +++ b/crates/nostream-core/src/negentropy.rs @@ -0,0 +1,20 @@ +//! NIP-77 negentropy set reconciliation. Mirrors `src/utils/negentropy.ts` and +//! `src/utils/negentropy-sessions.ts`. + +use crate::error::CoreError; + +/// One item in a negentropy exchange: a 32-byte id and its timestamp. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NegentropyItem { + pub id: [u8; 32], + pub timestamp: u64, +} + +/// Builds the initial reconciliation message from the local item set. +/// +/// Phase 1 stub — job 005; will encode the initial negentropy message; see `src/utils/negentropy.ts`. +pub fn reconcile(_local: &[NegentropyItem], _remote_message: &[u8]) -> Result, CoreError> { + Err(CoreError::NotImplemented { + module: "core::negentropy::reconcile", + }) +} diff --git a/crates/nostream-core/src/nip03.rs b/crates/nostream-core/src/nip03.rs new file mode 100644 index 000000000..2a169f8a9 --- /dev/null +++ b/crates/nostream-core/src/nip03.rs @@ -0,0 +1,13 @@ +//! NIP-03: OpenTimestamps attestations. Mirrors `src/utils/nip03.ts`. + +use crate::error::CoreError; +use crate::event::Event; + +/// Extracts the OpenTimestamps attestation attached to an event. +/// +/// 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", + }) +} diff --git a/crates/nostream-core/src/nip05.rs b/crates/nostream-core/src/nip05.rs new file mode 100644 index 000000000..cf4ce3b3c --- /dev/null +++ b/crates/nostream-core/src/nip05.rs @@ -0,0 +1,22 @@ +//! NIP-05: DNS-based identifiers. Mirrors `src/utils/nip05.ts` and `src/@types/nip05.ts`. + +use crate::error::CoreError; +use crate::event::Pubkey; + +/// A verification record: the identifier, its pubkey and the verification state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Nip05Verification { + pub pubkey: Pubkey, + pub identifier: String, + pub verified_at: Option, + pub failed_checks: u32, +} + +/// Fetches and verifies `https:///.well-known/nostr.json?name=`. +/// +/// Phase 1 stub — job 005; will fetch and check the identifier's pubkey binding; see `src/utils/nip05.ts`. +pub fn verify(_identifier: &str) -> Result { + Err(CoreError::NotImplemented { + module: "core::nip05::verify", + }) +} diff --git a/crates/nostream-core/src/nip25.rs b/crates/nostream-core/src/nip25.rs new file mode 100644 index 000000000..ae3419dca --- /dev/null +++ b/crates/nostream-core/src/nip25.rs @@ -0,0 +1,18 @@ +//! NIP-25: reactions. Mirrors `src/utils/nip25.ts`. + +use crate::error::CoreError; +use crate::event::{Event, EventId, Kind}; + +/// Extracts the reacted-to event id from a kind-7 reaction. +/// +/// Phase 1 stub — job 005; will read the last `e` tag of a kind-7 event; see `src/utils/nip25.ts`. +pub fn reacted_event_id(_event: &Event) -> Result { + Err(CoreError::NotImplemented { + module: "core::nip25::reacted_event_id", + }) +} + +/// Whether a kind counts as a reaction for scoring purposes. +pub fn is_reaction(kind: Kind) -> bool { + kind == Kind(7) +} diff --git a/crates/nostream-core/src/nip42.rs b/crates/nostream-core/src/nip42.rs new file mode 100644 index 000000000..a17e0de03 --- /dev/null +++ b/crates/nostream-core/src/nip42.rs @@ -0,0 +1,31 @@ +//! NIP-42: authentication of clients to relays. Mirrors `src/utils/nip42.ts` and +//! `src/utils/nip42-session.ts`. + +use crate::error::CoreError; +use crate::event::{Event, Pubkey}; + +/// The relay's per-connection authentication state. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AuthState { + pub challenge: Option, + pub authenticated_pubkey: Option, + pub expires_at: Option, +} + +/// Checks that a kind-22242 event answers this challenge and is signed by its author. +/// +/// Phase 1 stub — job 005; will verify the challenge tag, relay tag and signature; see `src/utils/nip42.ts`. +pub fn authenticate(_event: &Event, _challenge: &str, _relay_url: &str) -> Result { + Err(CoreError::NotImplemented { + module: "core::nip42::authenticate", + }) +} + +/// Generates a fresh challenge for a connection. +/// +/// Phase 1 stub — job 005; will mint a per-connection challenge; see `src/utils/nip42.ts`. +pub fn generate_challenge() -> Result { + Err(CoreError::NotImplemented { + module: "core::nip42::generate_challenge", + }) +} diff --git a/crates/nostream-core/src/nip43.rs b/crates/nostream-core/src/nip43.rs new file mode 100644 index 000000000..a6dbb5c1b --- /dev/null +++ b/crates/nostream-core/src/nip43.rs @@ -0,0 +1,28 @@ +//! NIP-43: relay membership and invite codes. Mirrors `src/utils/nip43.ts` and +//! `src/utils/nip43-invites.ts`. + +use crate::error::CoreError; +use crate::event::{Event, Kind, Pubkey}; + +/// An invite code with its mint parameters. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InviteCode { + pub code: String, + pub max_uses: u32, + pub expires_at: Option, + pub claimed_by: Vec, +} + +/// Mints a kind-28935 invite code for an authenticated requester. +/// +/// Phase 1 stub — job 005; will mint and persist an invite code; see `src/utils/nip43-invites.ts`. +pub fn mint_invite(_requester: &Pubkey, _max_uses: u32, _expiry_seconds: u64) -> Result { + Err(CoreError::NotImplemented { + module: "core::nip43::mint_invite", + }) +} + +/// Whether a kind is the membership join request (`src/utils/nip43.ts`). +pub fn is_join_request(kind: Kind) -> bool { + kind == Kind(28_934) +} diff --git a/crates/nostream-core/src/nip44.rs b/crates/nostream-core/src/nip44.rs new file mode 100644 index 000000000..afebd0c85 --- /dev/null +++ b/crates/nostream-core/src/nip44.rs @@ -0,0 +1,21 @@ +//! NIP-44: versioned encrypted payloads. Mirrors `src/utils/nip44.ts`. + +use crate::error::CoreError; + +/// Decrypts a NIP-44 v2 payload with the sender's shared secret. +/// +/// Phase 1 stub — job 005; will derive the conversation key and decrypt; see `src/utils/nip44.ts`. +pub fn decrypt(_payload: &str, _conversation_key: &[u8; 32]) -> Result { + Err(CoreError::NotImplemented { + module: "core::nip44::decrypt", + }) +} + +/// Encrypts a payload for a peer. +/// +/// Phase 1 stub — job 005; will pad and encrypt with the conversation key; see `src/utils/nip44.ts`. +pub fn encrypt(_plaintext: &str, _conversation_key: &[u8; 32]) -> Result { + Err(CoreError::NotImplemented { + module: "core::nip44::encrypt", + }) +} diff --git a/crates/nostream-core/src/nip56.rs b/crates/nostream-core/src/nip56.rs new file mode 100644 index 000000000..df3123759 --- /dev/null +++ b/crates/nostream-core/src/nip56.rs @@ -0,0 +1,23 @@ +//! NIP-56: reporting. Mirrors `src/utils/nip56.ts`. + +use crate::error::CoreError; +use crate::event::{Event, EventId, Pubkey}; + +/// The parsed target of a kind-1984 report. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Report { + pub reporter: Pubkey, + pub reported_pubkey: Option, + pub reported_event_id: Option, + pub report_type: String, + pub actionable: bool, +} + +/// 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", + }) +} diff --git a/crates/nostream-core/src/nip65.rs b/crates/nostream-core/src/nip65.rs new file mode 100644 index 000000000..b3b901b64 --- /dev/null +++ b/crates/nostream-core/src/nip65.rs @@ -0,0 +1,21 @@ +//! NIP-65: relay list metadata. Mirrors `src/utils/nip65.ts`. + +use crate::error::CoreError; +use crate::event::Event; + +/// One relay entry of a kind-10002 event: the URL and whether it is a read or write relay. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RelayListEntry { + pub url: String, + pub read: bool, + pub write: bool, +} + +/// Parses a kind-10002 event into its relay entries. +/// +/// Phase 1 stub — job 005; will read the `r` tags and their markers; see `src/utils/nip65.ts`. +pub fn parse_relay_list(_event: &Event) -> Result, CoreError> { + Err(CoreError::NotImplemented { + module: "core::nip65::parse_relay_list", + }) +} diff --git a/crates/nostream-core/src/nip66.rs b/crates/nostream-core/src/nip66.rs new file mode 100644 index 000000000..cc00d2d90 --- /dev/null +++ b/crates/nostream-core/src/nip66.rs @@ -0,0 +1,28 @@ +//! NIP-66: relay liveness and metadata events. Mirrors `src/utils/nip66-events.ts`, +//! `src/utils/nip66-nip11-tags.ts` and `src/utils/nip66-schedule.ts`. + +use crate::error::CoreError; +use crate::event::{Event, Kind}; + +/// A parsed kind-30166 relay discovery event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RelayDiscovery { + pub relay_url: String, + pub network: Option, + pub rtt_open: Option, + pub supported_nips: Vec, +} + +/// Parses a kind-30166 event into its tags. +/// +/// Phase 1 stub — job 005; will read `d`/`n`/`rtt-open`/`N` tags; see `src/utils/nip66-events.ts`. +pub fn parse_discovery(_event: &Event) -> Result { + Err(CoreError::NotImplemented { + module: "core::nip66::parse_discovery", + }) +} + +/// Whether a kind belongs to the NIP-66 monitor family. +pub fn is_monitor_kind(kind: Kind) -> bool { + matches!(kind.0, 10_166 | 30_166 | 50_166) +} diff --git a/crates/nostream-core/src/nip77.rs b/crates/nostream-core/src/nip77.rs new file mode 100644 index 000000000..583b488e2 --- /dev/null +++ b/crates/nostream-core/src/nip77.rs @@ -0,0 +1,23 @@ +//! NIP-77: negentropy sync. Mirrors `src/utils/nip77.ts`. +//! +//! Phase 1 has the types only. The `neg-open`/`neg-msg`/`neg-close` wire handlers exist at +//! the porting oracle (`041fe34e`) but not at this branch's capture commit, so no handler +//! module is named here. + +use crate::error::CoreError; + +/// The reconciliation protocol version and id set size advertised by a peer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NegOpenMessage { + pub subscription_id: String, + pub initial_message: Vec, +} + +/// Encodes a `NEG-OPEN` message for a subscription. +/// +/// Phase 1 stub — job 005; will encode the NIP-77 NEG-OPEN frame; see `src/utils/nip77.ts`. +pub fn encode_neg_open(_message: &NegOpenMessage) -> Result { + Err(CoreError::NotImplemented { + module: "core::nip77::encode_neg_open", + }) +} diff --git a/crates/nostream-core/src/nip98.rs b/crates/nostream-core/src/nip98.rs new file mode 100644 index 000000000..fe65a016c --- /dev/null +++ b/crates/nostream-core/src/nip98.rs @@ -0,0 +1,24 @@ +//! NIP-98: HTTP authentication. Mirrors `src/utils/nip98.ts` and `src/utils/nip98-replay.ts`. + +use crate::error::CoreError; +use crate::event::Pubkey; + +/// The claims of a NIP-98 `Authorization: Nostr ` header. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HttpAuth { + pub pubkey: Pubkey, + pub url: String, + pub method: String, + pub created_at: i64, + pub payload_hash: Option, +} + +/// Verifies a NIP-98 header against the request it accompanies. +/// +/// Phase 1 stub — job 005; will verify the event, the URL/method tags and the replay window; +/// see `src/utils/nip98.ts`. +pub fn verify(_header: &str, _url: &str, _method: &str, _body: &[u8], _max_skew_seconds: i64) -> Result { + Err(CoreError::NotImplemented { + module: "core::nip98::verify", + }) +} diff --git a/crates/nostream-core/src/pow.rs b/crates/nostream-core/src/pow.rs new file mode 100644 index 000000000..d7e16234a --- /dev/null +++ b/crates/nostream-core/src/pow.rs @@ -0,0 +1,13 @@ +//! Proof-of-work. Mirrors `src/utils/proof-of-work.ts` and `src/utils/adaptive-pow.ts`. + +use crate::error::CoreError; +use crate::event::Event; + +/// Whether the event id carries at least `difficulty` leading zero bits. +/// +/// Phase 1 stub — job 005; will count leading zero bits on the event id; see `src/utils/proof-of-work.ts`. +pub fn check(_event: &Event, _difficulty: u32) -> Result { + Err(CoreError::NotImplemented { + module: "core::pow::check", + }) +} diff --git a/crates/nostream-core/src/report_scoring.rs b/crates/nostream-core/src/report_scoring.rs new file mode 100644 index 000000000..2dc69c81c --- /dev/null +++ b/crates/nostream-core/src/report_scoring.rs @@ -0,0 +1,21 @@ +//! NIP-56 report scoring. Mirrors `src/utils/report-scoring.ts`. + +/// The weight a report carries, derived from the reporter's WoT distance. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReportWeight { + /// A trusted moderator's report: maximum weight and actionable. + Trusted, + /// A report from a known pubkey at the given distance. + Weighted { distance: u32 }, + /// A report from outside the trust graph: stored, never actionable. + 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", + }) +} diff --git a/crates/nostream-core/src/secret.rs b/crates/nostream-core/src/secret.rs new file mode 100644 index 000000000..01aa6c75c --- /dev/null +++ b/crates/nostream-core/src/secret.rs @@ -0,0 +1,27 @@ +//! Secrets held in settings. Mirrors `src/utils/secret.ts` / `src/@types/base.ts` `Secret`. + +use serde::{Deserialize, Serialize}; + +/// A hex-encoded secret key. +/// +/// `Debug` never prints the value, so a logged settings struct cannot leak it. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Secret(pub String); + +impl std::fmt::Debug for Secret { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.0.is_empty() { + formatter.write_str("Secret(\"\")") + } else { + formatter.write_str("Secret(***)") + } + } +} + +impl Secret { + /// The raw value. Callers must not log it. + pub fn expose(&self) -> &str { + &self.0 + } +} diff --git a/crates/nostream-core/src/validation.rs b/crates/nostream-core/src/validation.rs new file mode 100644 index 000000000..4c6e15347 --- /dev/null +++ b/crates/nostream-core/src/validation.rs @@ -0,0 +1,15 @@ +//! Event validation against the base schema. Mirrors `src/utils/validation.ts` and +//! `src/utils/event.ts` (`validateEvent`). + +use crate::error::CoreError; +use crate::event::Event; + +/// Validates id/sig shape, kind bounds and tag structure before storage. +/// +/// Phase 1 stub — job 005; will check the base schema before an event reaches storage; +/// see `src/utils/validation.ts`. +pub fn validate_event(_event: &Event) -> Result<(), CoreError> { + Err(CoreError::NotImplemented { + module: "core::validation::validate_event", + }) +} From 96938e999581e86b59a89aa1e00c58c1972e9873 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 15:22:28 -0400 Subject: [PATCH 03/32] feat(config): implement the settings family end to end The typed schema for all 18 shipped sections (61 capture types), the loader with the capture's fallback-to-defaults rule, mergeDeepRight over Value, the validator with the capture's issue strings and ordering, the dot-path editor and value parser, redaction, the guided catalogue, and the admin-notification merge. The parity fixture carries 17 measured cases from 242a3001 (the last two pin the deferred NIP-77 surface as absent); the round-trip test proves every rename. --- crates/nostream-config/Cargo.toml | 21 + crates/nostream-config/src/error.rs | 42 + crates/nostream-config/src/guided.rs | 364 +++++++++ crates/nostream-config/src/issue.rs | 31 + crates/nostream-config/src/lib.rs | 39 + crates/nostream-config/src/load.rs | 160 ++++ crates/nostream-config/src/notifications.rs | 283 +++++++ crates/nostream-config/src/path.rs | 333 ++++++++ crates/nostream-config/src/paths.rs | 41 + crates/nostream-config/src/redaction.rs | 88 +++ crates/nostream-config/src/settings.rs | 737 ++++++++++++++++++ crates/nostream-config/src/validate.rs | 467 +++++++++++ crates/nostream-config/src/value.rs | 149 ++++ .../nostream-config/tests/backend_values.rs | 52 ++ .../tests/capture/gen-ts-issues.mjs | 134 ++++ .../nostream-config/tests/defaults_parity.rs | 161 ++++ .../tests/defaults_roundtrip.rs | 108 +++ .../tests/fixtures/ts-issues.json | 258 ++++++ crates/nostream-config/tests/guided.rs | 86 ++ crates/nostream-config/tests/load.rs | 193 +++++ .../tests/notifications_merge.rs | 125 +++ crates/nostream-config/tests/path_editor.rs | 194 +++++ crates/nostream-config/tests/redaction.rs | 99 +++ crates/nostream-config/tests/ts_parity.rs | 288 +++++++ crates/nostream-config/tests/validator.rs | 593 ++++++++++++++ 25 files changed, 5046 insertions(+) create mode 100644 crates/nostream-config/Cargo.toml create mode 100644 crates/nostream-config/src/error.rs create mode 100644 crates/nostream-config/src/guided.rs create mode 100644 crates/nostream-config/src/issue.rs create mode 100644 crates/nostream-config/src/lib.rs create mode 100644 crates/nostream-config/src/load.rs create mode 100644 crates/nostream-config/src/notifications.rs create mode 100644 crates/nostream-config/src/path.rs create mode 100644 crates/nostream-config/src/paths.rs create mode 100644 crates/nostream-config/src/redaction.rs create mode 100644 crates/nostream-config/src/settings.rs create mode 100644 crates/nostream-config/src/validate.rs create mode 100644 crates/nostream-config/src/value.rs create mode 100644 crates/nostream-config/tests/backend_values.rs create mode 100644 crates/nostream-config/tests/capture/gen-ts-issues.mjs create mode 100644 crates/nostream-config/tests/defaults_parity.rs create mode 100644 crates/nostream-config/tests/defaults_roundtrip.rs create mode 100644 crates/nostream-config/tests/fixtures/ts-issues.json create mode 100644 crates/nostream-config/tests/guided.rs create mode 100644 crates/nostream-config/tests/load.rs create mode 100644 crates/nostream-config/tests/notifications_merge.rs create mode 100644 crates/nostream-config/tests/path_editor.rs create mode 100644 crates/nostream-config/tests/redaction.rs create mode 100644 crates/nostream-config/tests/ts_parity.rs create mode 100644 crates/nostream-config/tests/validator.rs diff --git a/crates/nostream-config/Cargo.toml b/crates/nostream-config/Cargo.toml new file mode 100644 index 000000000..af696789a --- /dev/null +++ b/crates/nostream-config/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "nostream-config" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Typed settings, loading and validation for the nostream rewrite" + +[dependencies] +nostream-core.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_yaml.workspace = true +thiserror.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/nostream-config/src/error.rs b/crates/nostream-config/src/error.rs new file mode 100644 index 000000000..14f5b9c79 --- /dev/null +++ b/crates/nostream-config/src/error.rs @@ -0,0 +1,42 @@ +//! Errors that abort a settings load. Validation findings are not errors: they are +//! [`crate::Issue`] values, because the capture's validator never throws. + +use std::path::PathBuf; + +use thiserror::Error; + +/// A settings file could not be read, parsed or interpreted. +#[derive(Debug, Error)] +pub enum ConfigError { + /// A settings or defaults file could not be read. + #[error("failed to read {path}: {source}")] + Read { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + /// A YAML or JSON file is not valid. + #[error("failed to parse {path}: {source}")] + Parse { + path: PathBuf, + #[source] + source: serde_yaml::Error, + }, + + /// A JSON settings file is not valid. + #[error("failed to parse {path}: {source}")] + ParseJson { + path: PathBuf, + #[source] + source: serde_json::Error, + }, + + /// The document is valid YAML but does not fit the typed schema. + #[error("settings do not fit the typed schema: {0}")] + Typed(#[from] serde_yaml::Error), + + /// The defaults document is not a mapping. + #[error("{path} does not contain a settings mapping")] + NotAMapping { path: PathBuf }, +} diff --git a/crates/nostream-config/src/guided.rs b/crates/nostream-config/src/guided.rs new file mode 100644 index 000000000..3495ee9bd --- /dev/null +++ b/crates/nostream-config/src/guided.rs @@ -0,0 +1,364 @@ +//! The guided settings catalogue. Mirrors `src/utils/settings-guided-schema.ts` at the +//! capture commit (the oracle's `nip77` entries and `requireSafePositiveIntegerSettingValue` +//! are deferred with the five-file forward port; spec §6). + +use serde::{Deserialize, Serialize}; +use std::sync::LazyLock; + +/// The editor control a field uses (`GuidedSettingFieldType`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum GuidedSettingFieldType { + Boolean, + Number, + #[serde(rename = "string")] + Text, + Select, + StringArray, +} + +/// A value validator (`GuidedSettingFieldValidator`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GuidedSettingValidator { + NonEmpty, + SafeNonNegativeInteger, +} + +impl GuidedSettingValidator { + /// Applies the validator, returning the capture's message on failure. + pub fn validate(self, value: &str) -> Option<&'static str> { + match self { + Self::NonEmpty => require_non_empty_setting_value(value), + Self::SafeNonNegativeInteger => require_safe_non_negative_integer_setting_value(value), + } + } +} + +/// One editable field (`GuidedSettingField`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GuidedSettingField { + pub label: String, + pub path: String, + #[serde(rename = "type")] + pub field_type: GuidedSettingFieldType, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub options: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub placeholder: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub validate: Option, +} + +/// One editor category (`GuidedSettingCategory`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GuidedSettingCategory { + pub value: String, + pub label: String, + pub settings: Vec, +} + +/// `Value is required` when the value is blank. +pub fn require_non_empty_setting_value(value: &str) -> Option<&'static str> { + if value.trim().is_empty() { + Some("Value is required") + } else { + None + } +} + +/// `^[0-9]+$`, then within `Number.MAX_SAFE_INTEGER`. +pub fn require_safe_non_negative_integer_setting_value(value: &str) -> Option<&'static str> { + let trimmed = value.trim(); + if trimmed.is_empty() || !trimmed.bytes().all(|byte| byte.is_ascii_digit()) { + return Some("Value must be a non-negative integer"); + } + match trimmed.parse::() { + Ok(number) if number <= 9_007_199_254_740_991 => None, + Ok(_) => Some("Value must be a safe integer"), + Err(_) => Some("Value must be a safe integer"), + } +} + +fn field(label: &str, path: &str, field_type: GuidedSettingFieldType) -> GuidedSettingField { + GuidedSettingField { + label: label.to_owned(), + path: path.to_owned(), + field_type, + options: Vec::new(), + placeholder: None, + validate: None, + } +} + +fn validated( + label: &str, + path: &str, + field_type: GuidedSettingFieldType, + validator: GuidedSettingValidator, +) -> GuidedSettingField { + GuidedSettingField { + validate: Some(validator), + ..field(label, path, field_type) + } +} + +fn text(label: &str, path: &str, placeholder: &str) -> GuidedSettingField { + GuidedSettingField { + placeholder: Some(placeholder.to_owned()), + ..field(label, path, GuidedSettingFieldType::Text) + } +} + +fn validated_text( + label: &str, + path: &str, + placeholder: &str, + validator: GuidedSettingValidator, +) -> GuidedSettingField { + GuidedSettingField { + validate: Some(validator), + ..text(label, path, placeholder) + } +} + +fn select(label: &str, path: &str, options: &[&str]) -> GuidedSettingField { + GuidedSettingField { + options: options.iter().map(|option| (*option).to_owned()).collect(), + ..field(label, path, GuidedSettingFieldType::Select) + } +} + +fn string_array(label: &str, path: &str, placeholder: &str) -> GuidedSettingField { + GuidedSettingField { + placeholder: Some(placeholder.to_owned()), + ..field(label, path, GuidedSettingFieldType::StringArray) + } +} + +fn category(value: &str, label: &str, settings: Vec) -> GuidedSettingCategory { + GuidedSettingCategory { + value: value.to_owned(), + label: label.to_owned(), + settings, + } +} + +fn build_categories() -> Vec { + use GuidedSettingFieldType::{Boolean, Number}; + use GuidedSettingValidator::{NonEmpty, SafeNonNegativeInteger}; + + vec![ + category( + "relay-info", + "Relay Info", + vec![ + validated_text("Relay URL", "info.relay_url", "wss://relay.example.com", NonEmpty), + validated_text("Relay name", "info.name", "relay.example.com", NonEmpty), + text( + "Description", + "info.description", + "A nostr relay written in Typescript.", + ), + text("Contact", "info.contact", "mailto:operator@your-domain.com"), + text("Relay pubkey (hex)", "info.pubkey", "replace-with-your-pubkey-in-hex"), + ], + ), + category( + "payments", + "Payments", + vec![ + field("Enable payments", "payments.enabled", Boolean), + select( + "Payment processor", + "payments.processor", + &["zebedee", "lnbits", "lnurl", "nodeless", "opennode"], + ), + field( + "Admission fee enabled", + "payments.feeSchedules.admission[0].enabled", + Boolean, + ), + validated( + "Admission fee amount (msats)", + "payments.feeSchedules.admission[0].amount", + Number, + SafeNonNegativeInteger, + ), + ], + ), + category( + "network", + "Network", + vec![ + validated( + "Max payload size", + "network.maxPayloadSize", + Number, + SafeNonNegativeInteger, + ), + validated("Worker count", "workers.count", Number, SafeNonNegativeInteger), + ], + ), + category( + "limits", + "Limits", + vec![ + select( + "Rate limiter strategy", + "limits.rateLimiter.strategy", + &["ewma", "sliding_window"], + ), + validated( + "Maximum event content length", + "limits.event.content[0].maxLength", + Number, + SafeNonNegativeInteger, + ), + validated( + "Minimum pubkey balance", + "limits.event.pubkey.minBalance", + Number, + SafeNonNegativeInteger, + ), + validated( + "Maximum future event time (seconds)", + "limits.event.createdAt.maxPositiveDelta", + Number, + SafeNonNegativeInteger, + ), + string_array( + "Pubkey blacklist", + "limits.event.pubkey.blacklist", + "One hex pubkey per line", + ), + string_array( + "Kind whitelist", + "limits.event.kind.whitelist", + "One kind or range per line, e.g. 1 or 10000-19999", + ), + ], + ), + category( + "client", + "Client", + vec![ + validated( + "Max subscriptions per connection", + "limits.client.subscription.maxSubscriptions", + Number, + SafeNonNegativeInteger, + ), + validated( + "Max filters per subscription", + "limits.client.subscription.maxFilters", + Number, + SafeNonNegativeInteger, + ), + validated( + "Max filter values", + "limits.client.subscription.maxFilterValues", + Number, + SafeNonNegativeInteger, + ), + validated( + "Max REQ limit value", + "limits.client.subscription.maxLimit", + Number, + SafeNonNegativeInteger, + ), + ], + ), + category( + "retention", + "Retention", + vec![field("Event retention (days)", "limits.event.retention.maxDays", Number)], + ), + category( + "nip-features", + "NIP Features", + vec![ + select("NIP-05 mode", "nip05.mode", &["enabled", "passive", "disabled"]), + string_array( + "NIP-05 domain whitelist", + "nip05.domainWhitelist", + "One domain per line", + ), + string_array( + "NIP-05 domain blacklist", + "nip05.domainBlacklist", + "One domain per line", + ), + field("Enable NIP-45", "nip45.enabled", Boolean), + field("Enable NIP-50", "nip50.enabled", Boolean), + validated( + "NIP-50 max query length", + "nip50.maxQueryLength", + Number, + SafeNonNegativeInteger, + ), + field("Enable Web of Trust", "wot.enabled", Boolean), + text("WoT seed pubkey (hex)", "wot.seedPubkey", "Relay owner pubkey in hex"), + validated( + "WoT minimum followers", + "wot.minimumFollowers", + Number, + SafeNonNegativeInteger, + ), + field("Enable NIP-56 content reporting", "nip56.enabled", Boolean), + string_array( + "NIP-56 trusted moderator pubkeys (hex)", + "nip56.trustedModerators", + "One pubkey per line", + ), + field( + "Hide content matching actionable NIP-56 reports", + "nip56.hideActionableReports", + Boolean, + ), + ], + ), + category( + "admin", + "Admin", + vec![ + field("Enable admin console", "admin.enabled", Boolean), + validated( + "Session TTL (seconds)", + "admin.sessionTtlSeconds", + Number, + SafeNonNegativeInteger, + ), + field( + "Enable operator notifications", + "admin.notifications.enabled", + Boolean, + ), + ], + ), + ] +} + +/// Every guided category, in the capture's order. +pub fn guided_setting_categories() -> &'static [GuidedSettingCategory] { + static CATEGORIES: LazyLock> = LazyLock::new(build_categories); + &CATEGORIES +} + +/// Looks up a category by its `value`. +pub fn get_guided_setting_category(value: &str) -> Option<&'static GuidedSettingCategory> { + guided_setting_categories() + .iter() + .find(|category| category.value == value) +} + +/// Looks up one field of a category by its path. +pub fn get_guided_setting_field( + category_value: &str, + path: &str, +) -> Option<&'static GuidedSettingField> { + get_guided_setting_category(category_value)? + .settings + .iter() + .find(|field| field.path == path) +} diff --git a/crates/nostream-config/src/issue.rs b/crates/nostream-config/src/issue.rs new file mode 100644 index 000000000..a9e06242d --- /dev/null +++ b/crates/nostream-config/src/issue.rs @@ -0,0 +1,31 @@ +//! Validation findings. Mirrors `src/utils/settings-config.ts` `ValidationIssue`. + +use std::fmt; + +/// One validation finding: a dot-path into the settings document and the capture's message. +/// +/// `Display` renders `{path}: {message}`, which is what `nostreamd --check` and +/// `nostream-cli config validate` print one per line. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct Issue { + /// The dot-path of the offending value (`a.b[0].c`; `$` for the document root). + pub path: String, + /// The operator-facing message, byte-identical to the capture's. + pub message: String, +} + +impl Issue { + /// Builds an issue from anything string-like. + pub fn new(path: impl Into, message: impl Into) -> Self { + Self { + path: path.into(), + message: message.into(), + } + } +} + +impl fmt::Display for Issue { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {}", self.path, self.message) + } +} diff --git a/crates/nostream-config/src/lib.rs b/crates/nostream-config/src/lib.rs new file mode 100644 index 000000000..6863ee215 --- /dev/null +++ b/crates/nostream-config/src/lib.rs @@ -0,0 +1,39 @@ +//! `nostream-config` owns the settings family: the typed schema, the loader, the deep merge, +//! the validator (with the capture's exact issue strings), the dot-path editor, secret +//! redaction, the guided editor catalogue and the admin-notification merge. +//! +//! It owns these TypeScript paths from the capture commit +//! (`242a3001d77149e4d70151ec03060c4ef44a1547`): +//! +//! - `src/@types/settings.ts`, `src/@types/operator-notifications.ts` +//! - `src/utils/settings.ts`, `src/utils/settings-config.ts`, `src/utils/settings-paths.ts`, +//! `src/utils/settings-redaction.ts`, `src/utils/settings-guided-schema.ts`, +//! `src/utils/admin-notifications-settings.ts` +//! - `src/factories/settings-factory.ts` (its `new SettingsStatic()` wiring becomes +//! [`load`], since the daemon owns the loaded value) +//! +//! Deliberately **not** in this crate (named so an omission is not mistaken for a gap): +//! `saveSettings`, `appendSettingsAuditLog`, `listSettingsBackups`, `restoreSettingsBackup`, +//! `filterSettingsAgainstDefaults` and `watchSettings` (filesystem hot reload) belong to the +//! admin job and the daemon's reload loop. The NIP-77 settings surface (`nip77` defaults, the +//! `relay_url` protocol rule) is deferred with the five-file forward port from `041fe34e`; +//! this crate models exactly what this branch's tree contains. + +mod error; +pub mod guided; +mod issue; +pub mod load; +pub mod notifications; +pub mod path; +pub mod paths; +pub mod redaction; +pub mod settings; +pub mod validate; +pub mod value; + +pub use error::ConfigError; +pub use issue::Issue; +pub use load::{load, load_defaults, load_user_settings, merge, LoadOutcome}; +pub use paths::SettingsPaths; +pub use settings::Settings; +pub use validate::{validate, validate_document}; diff --git a/crates/nostream-config/src/load.rs b/crates/nostream-config/src/load.rs new file mode 100644 index 000000000..93ccff192 --- /dev/null +++ b/crates/nostream-config/src/load.rs @@ -0,0 +1,160 @@ +//! Loading, merging and the fallback rule. Mirrors `src/utils/settings.ts` +//! `SettingsStatic.createSettings()` and the loader half of `src/utils/settings-config.ts`. +//! +//! The fallback rule is the capture's: a user configuration that cannot be read or does not +//! validate never aborts the process. It is reported as [`Issue`] values and the caller gets +//! the shipped defaults, so a broken `settings.yaml` cannot stop a relay from starting. + +use std::fs; +use std::path::Path; + +use serde_yaml::Value; + +use crate::error::ConfigError; +use crate::issue::Issue; +use crate::paths::SettingsPaths; +use crate::settings::Settings; +use crate::validate::validate_document; +use crate::value::merge_deep_right; + +/// Parses the shipped defaults document. +pub fn load_defaults(path: &Path) -> Result { + let raw = fs::read_to_string(path).map_err(|source| ConfigError::Read { + path: path.to_path_buf(), + source, + })?; + let document: Value = serde_yaml::from_str(&raw).map_err(|source| ConfigError::Parse { + path: path.to_path_buf(), + source, + })?; + if !document.is_mapping() { + return Err(ConfigError::NotAMapping { + path: path.to_path_buf(), + }); + } + Ok(document) +} + +/// Reads one settings file, choosing YAML or JSON by extension. +pub fn load_document(path: &Path) -> Result { + let raw = fs::read_to_string(path).map_err(|source| ConfigError::Read { + path: path.to_path_buf(), + source, + })?; + if path.extension().is_some_and(|extension| extension == "json") { + let document: Value = + serde_json::from_str(&raw).map_err(|source| ConfigError::ParseJson { + path: path.to_path_buf(), + source, + })?; + return Ok(document); + } + let document: Value = serde_yaml::from_str(&raw).map_err(|source| ConfigError::Parse { + path: path.to_path_buf(), + source, + })?; + Ok(document) +} + +/// Reads the user's overrides: `settings.yaml` first, then the deprecated `settings.json`. +/// +/// Returns `None` when neither file exists. An empty YAML file is an empty override set. +pub fn load_user_settings(config_base_dir: &Path) -> Result, ConfigError> { + fs::create_dir_all(config_base_dir).map_err(|source| ConfigError::Read { + path: config_base_dir.to_path_buf(), + source, + })?; + + let settings_file = config_base_dir.join("settings.yaml"); + if settings_file.is_file() { + let document = load_document(&settings_file)?; + return Ok(Some(if document.is_null() { + Value::Mapping(serde_yaml::Mapping::new()) + } else { + document + })); + } + + let legacy_file = config_base_dir.join("settings.json"); + if legacy_file.is_file() { + let document = load_document(&legacy_file)?; + return Ok(Some(if document.is_null() { + Value::Mapping(serde_yaml::Mapping::new()) + } else { + document + })); + } + + Ok(None) +} + +/// Deep-merges user overrides over the defaults (`mergeDeepRight`). +pub fn merge(defaults: &Value, user: &Value) -> Value { + merge_deep_right(defaults, user) +} + +/// The result of loading the effective settings. +#[derive(Debug, Clone)] +pub struct LoadOutcome { + /// The effective typed settings (the defaults when `used_defaults` is true). + pub settings: Settings, + /// Findings from the user's document, in the validator's order. Empty on success. + pub issues: Vec, + /// Whether the user's document was rejected and the defaults were substituted. + pub used_defaults: bool, +} + +/// Loads and validates the effective settings, falling back to the defaults on any failure. +pub fn load(paths: &SettingsPaths) -> Result { + let defaults = load_defaults(&paths.default_settings_file)?; + let defaults_settings: Settings = + serde_yaml::from_value(defaults.clone()).map_err(ConfigError::Typed)?; + + let user = match load_user_settings(&paths.config_base_dir) { + Ok(user) => user, + Err(error) => { + return Ok(LoadOutcome { + settings: defaults_settings, + issues: vec![Issue::new( + "$", + format!("failed to load user settings: {error}"), + )], + used_defaults: true, + }); + } + }; + + let Some(user) = user else { + return Ok(LoadOutcome { + settings: defaults_settings, + issues: Vec::new(), + used_defaults: false, + }); + }; + + let merged = merge(&defaults, &user); + let issues = validate_document(&merged, &defaults); + if !issues.is_empty() { + return Ok(LoadOutcome { + settings: defaults_settings, + issues, + used_defaults: true, + }); + } + + match serde_yaml::from_value::(merged) { + Ok(settings) => Ok(LoadOutcome { + settings, + issues, + used_defaults: false, + }), + Err(error) => Ok(LoadOutcome { + settings: defaults_settings, + issues: vec![Issue::new( + "$", + format!("settings do not fit the typed schema: {error}"), + )], + used_defaults: true, + }), + } +} diff --git a/crates/nostream-config/src/notifications.rs b/crates/nostream-config/src/notifications.rs new file mode 100644 index 000000000..6b73039b7 --- /dev/null +++ b/crates/nostream-config/src/notifications.rs @@ -0,0 +1,283 @@ +//! Admin notification settings. Mirrors `src/utils/admin-notifications-settings.ts` and +//! `src/@types/operator-notifications.ts`. + +use serde::{Deserialize, Serialize}; + +use crate::redaction::redact_settings_secrets; + +/// The placeholder the admin UI sends back instead of a secret it was shown. +pub const REDACTED_SECRET: &str = "***"; + +/// A notification channel (`OperatorNotificationChannelType`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum OperatorNotificationChannelType { + Http, + Discord, + Slack, + Telegram, +} + +/// One notification target (`OperatorNotificationTarget`). +/// +/// `enabled` is optional here although the TypeScript type requires it: the validator treats +/// it as optional, and a typed parse that disagrees with the validator would turn a valid +/// document into a fallback-to-defaults. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OperatorNotificationTarget { + pub id: String, + #[serde(rename = "type")] + pub target_type: OperatorNotificationChannelType, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bot_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub chat_id: Option, +} + +/// The per-event toggles (`OperatorNotificationEventToggles`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct OperatorNotificationEventToggles { + #[serde( + rename = "admission.invoice.created", + skip_serializing_if = "Option::is_none" + )] + pub admission_invoice_created: Option, + #[serde(rename = "admission.invoice.paid", skip_serializing_if = "Option::is_none")] + pub admission_invoice_paid: Option, + #[serde( + rename = "admission.invoice.failed", + skip_serializing_if = "Option::is_none" + )] + pub admission_invoice_failed: Option, + #[serde(rename = "settings.changed", skip_serializing_if = "Option::is_none")] + pub settings_changed: Option, + #[serde(rename = "relay.restarted", skip_serializing_if = "Option::is_none")] + pub relay_restarted: Option, +} + +/// Retry policy (`OperatorNotificationRetrySettings`). +/// +/// Both fields are optional: the capture's merge reads each with `??`, and a user block that +/// names only one keeps the other from the defaults. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OperatorNotificationRetrySettings { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_attempts: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_delay_ms: Option, +} + +/// `admin.notifications` (`AdminNotificationsSettings`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminNotificationsSettings { + pub enabled: bool, + pub targets: Vec, + pub events: OperatorNotificationEventToggles, + pub retry: OperatorNotificationRetrySettings, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delivery_log_retention_days: Option, +} + +/// A target in an incoming patch: every field the admin UI may send. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NotificationTargetPatch { + pub id: String, + #[serde(rename = "type")] + pub target_type: OperatorNotificationChannelType, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bot_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub chat_id: Option, +} + +/// Retry fields in an incoming patch. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NotificationRetryPatch { + #[serde(skip_serializing_if = "Option::is_none")] + pub max_attempts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub base_delay_ms: Option, +} + +/// An incoming admin patch (`Partial`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminNotificationsPatch { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub targets: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub events: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub retry: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub delivery_log_retention_days: Option, +} + +fn merge_events( + defaults: Option<&OperatorNotificationEventToggles>, + configured: Option<&OperatorNotificationEventToggles>, +) -> OperatorNotificationEventToggles { + let defaults = defaults.cloned().unwrap_or_default(); + let Some(configured) = configured else { + return defaults; + }; + OperatorNotificationEventToggles { + admission_invoice_created: configured + .admission_invoice_created + .or(defaults.admission_invoice_created), + admission_invoice_paid: configured + .admission_invoice_paid + .or(defaults.admission_invoice_paid), + admission_invoice_failed: configured + .admission_invoice_failed + .or(defaults.admission_invoice_failed), + settings_changed: configured.settings_changed.or(defaults.settings_changed), + relay_restarted: configured.relay_restarted.or(defaults.relay_restarted), + } +} + +/// The effective notifications block: configured values over defaults, with the capture's +/// numeric fallbacks (5 attempts, 1000 ms, 30 days). +pub fn get_merged_admin_notifications( + configured: Option<&AdminNotificationsSettings>, + defaults: Option<&AdminNotificationsSettings>, +) -> AdminNotificationsSettings { + AdminNotificationsSettings { + enabled: configured + .map(|settings| settings.enabled) + .or_else(|| defaults.map(|settings| settings.enabled)) + .unwrap_or(false), + targets: configured + .map(|settings| settings.targets.clone()) + .or_else(|| defaults.map(|settings| settings.targets.clone())) + .unwrap_or_default(), + events: merge_events( + defaults.map(|settings| &settings.events), + configured.map(|settings| &settings.events), + ), + retry: OperatorNotificationRetrySettings { + max_attempts: configured + .and_then(|settings| settings.retry.max_attempts) + .or_else(|| defaults.and_then(|settings| settings.retry.max_attempts)) + .or(Some(5)), + base_delay_ms: configured + .and_then(|settings| settings.retry.base_delay_ms) + .or_else(|| defaults.and_then(|settings| settings.retry.base_delay_ms)) + .or(Some(1000)), + }, + delivery_log_retention_days: configured + .and_then(|settings| settings.delivery_log_retention_days) + .or_else(|| defaults.and_then(|settings| settings.delivery_log_retention_days)) + .or(Some(30)), + } +} + +/// The merged block with secrets replaced by `***`, for the admin UI. +pub fn get_redacted_admin_notifications( + merged: &AdminNotificationsSettings, + defaults: Option<&AdminNotificationsSettings>, +) -> Result { + let document = serde_yaml::to_value(merged)?; + let redacted = redact_settings_secrets(&document); + let settings: AdminNotificationsSettings = serde_yaml::from_value(redacted)?; + Ok(get_merged_admin_notifications(Some(&settings), defaults)) +} + +fn is_redacted_secret(value: Option<&str>) -> bool { + value == Some(REDACTED_SECRET) +} + +fn merge_target_secrets( + incoming: &NotificationTargetPatch, + existing: Option<&OperatorNotificationTarget>, +) -> OperatorNotificationTarget { + let mut merged = OperatorNotificationTarget { + id: incoming.id.clone(), + target_type: incoming.target_type, + enabled: Some( + incoming + .enabled + .or(existing.and_then(|target| target.enabled)) + .unwrap_or(false), + ), + url: incoming.url.clone(), + bot_token: incoming.bot_token.clone(), + chat_id: incoming + .chat_id + .clone() + .or_else(|| existing.and_then(|target| target.chat_id.clone())), + }; + + if is_redacted_secret(incoming.url.as_deref()) { + merged.url = existing.and_then(|target| target.url.clone()); + } else if incoming.url.is_none() && existing.and_then(|target| target.url.clone()).is_some() { + merged.url = existing.and_then(|target| target.url.clone()); + } + + if is_redacted_secret(incoming.bot_token.as_deref()) { + merged.bot_token = existing.and_then(|target| target.bot_token.clone()); + } else if incoming.bot_token.is_none() + && existing.and_then(|target| target.bot_token.clone()).is_some() + { + merged.bot_token = existing.and_then(|target| target.bot_token.clone()); + } + + merged +} + +/// Applies an admin patch: scalars replace, events and retry merge, targets replace wholesale +/// while a redacted placeholder inherits the stored secret. +pub fn merge_admin_notifications_patch( + current: &AdminNotificationsSettings, + patch: &AdminNotificationsPatch, +) -> AdminNotificationsSettings { + let events = merge_events(Some(¤t.events), patch.events.as_ref()); + let retry = OperatorNotificationRetrySettings { + max_attempts: patch + .retry + .as_ref() + .and_then(|retry| retry.max_attempts) + .or(current.retry.max_attempts), + base_delay_ms: patch + .retry + .as_ref() + .and_then(|retry| retry.base_delay_ms) + .or(current.retry.base_delay_ms), + }; + + let targets = match &patch.targets { + Some(incoming) => incoming + .iter() + .map(|target| { + let existing = current.targets.iter().find(|entry| entry.id == target.id); + merge_target_secrets(target, existing) + }) + .collect(), + None => current.targets.clone(), + }; + + AdminNotificationsSettings { + enabled: patch.enabled.unwrap_or(current.enabled), + targets, + events, + retry, + delivery_log_retention_days: patch + .delivery_log_retention_days + .or(current.delivery_log_retention_days), + } +} diff --git a/crates/nostream-config/src/path.rs b/crates/nostream-config/src/path.rs new file mode 100644 index 000000000..efd7f3975 --- /dev/null +++ b/crates/nostream-config/src/path.rs @@ -0,0 +1,333 @@ +//! The dot-path editor and value parser. Mirrors the path half of +//! `src/utils/settings-config.ts` (`parsePath`, `formatPathTokens`, `getByPath`, `setByPath`, +//! `validatePathAgainstDefaults`, `parseValue`, `parseTypedValue`). + +use serde_yaml::{Mapping, Value}; +use thiserror::Error; + +use crate::issue::Issue; + +/// A parsed path element: a key or an array index. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PathToken { + Key(String), + Index(usize), +} + +/// A path that cannot be parsed or applied. Messages are the capture's. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum PathError { + #[error("Path is required")] + Required, + #[error("Invalid path segment in: {0}")] + InvalidSegmentIn(String), + #[error("Invalid path segment: {0}")] + InvalidSegment(String), + #[error("Cannot set key {0} on non-object path")] + SetKeyOnNonObject(String), + #[error("Cannot index non-array path at [{0}]")] + IndexNonArray(usize), +} + +/// Keys that would reach a JavaScript prototype. Mirrors `RESERVED_PATH_KEYS`. +const RESERVED_PATH_KEYS: [&str; 3] = ["__proto__", "prototype", "constructor"]; + +/// Renders tokens back into the `a.b[0].c` form. +pub fn format_path_tokens(tokens: &[PathToken]) -> String { + let mut out = String::new(); + for token in tokens { + match token { + PathToken::Key(key) => { + if !out.is_empty() { + out.push('.'); + } + out.push_str(key); + } + PathToken::Index(index) => { + out.push('['); + out.push_str(&index.to_string()); + out.push(']'); + } + } + } + out +} + +/// Parses `a.b[0].c` into tokens. +/// +/// Grammar per segment: `^([A-Za-z_][A-Za-z0-9_]*)(\[(\d+)\])*$`, with the reserved-key guard +/// applied to the key part. +pub fn parse_path(path: &str) -> Result, PathError> { + let input = path.trim(); + if input.is_empty() { + return Err(PathError::Required); + } + + let mut tokens = Vec::new(); + for segment in input.split('.').map(str::trim) { + if segment.is_empty() { + return Err(PathError::InvalidSegmentIn(path.to_owned())); + } + let Some((key, indexes)) = split_segment(segment) else { + return Err(PathError::InvalidSegment(segment.to_owned())); + }; + if RESERVED_PATH_KEYS.contains(&key.as_str()) { + return Err(PathError::InvalidSegment(segment.to_owned())); + } + tokens.push(PathToken::Key(key)); + for index in indexes { + tokens.push(PathToken::Index(index)); + } + } + Ok(tokens) +} + +/// Splits one segment into its key and its indexes, or `None` when it does not fit the grammar. +fn split_segment(segment: &str) -> Option<(String, Vec)> { + let mut chars = segment.char_indices(); + let (_, first) = chars.next()?; + if !(first.is_ascii_alphabetic() || first == '_') { + return None; + } + let mut key_end = first.len_utf8(); + for (offset, character) in chars { + if character.is_ascii_alphanumeric() || character == '_' { + key_end = offset + character.len_utf8(); + } else { + break; + } + } + let key = segment[..key_end].to_owned(); + let mut rest = &segment[key_end..]; + let mut indexes = Vec::new(); + while !rest.is_empty() { + let inner = rest.strip_prefix('[')?; + let close = inner.find(']')?; + let digits = &inner[..close]; + if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + indexes.push(digits.parse::().ok()?); + rest = &inner[close + 1..]; + } + Some((key, indexes)) +} + +/// Reads a value by path; `Ok(None)` means the path does not exist (`undefined` in the capture). +pub fn get_by_path<'a>(document: &'a Value, path: &str) -> Result, PathError> { + let tokens = parse_path(path)?; + let mut current = document; + for token in &tokens { + match token { + PathToken::Key(key) => match current { + Value::Mapping(mapping) => match mapping.get(key.as_str()) { + Some(value) => current = value, + None => return Ok(None), + }, + _ => return Ok(None), + }, + PathToken::Index(index) => match current { + Value::Sequence(sequence) => match sequence.get(*index) { + Some(value) => current = value, + None => return Ok(None), + }, + _ => return Ok(None), + }, + } + } + Ok(Some(current)) +} + +/// Ensures `target` has at least `minimum + 1` entries, padding with `null`. +/// +/// The capture pads with JavaScript `undefined`; `serde_yaml::Value` has no such value, so the +/// padding is `null`. The only observable difference is a shape-validation message at a padded +/// slot, and no settings path needs one. +fn ensure_array_length(target: &mut Vec, minimum: usize) { + while target.len() <= minimum { + target.push(Value::Null); + } +} + +fn next_container(next_is_index: bool) -> Value { + if next_is_index { + Value::Sequence(Vec::new()) + } else { + Value::Mapping(Mapping::new()) + } +} + +fn is_compatible(existing: &Value, next_is_index: bool) -> bool { + match existing { + Value::Sequence(_) => next_is_index, + Value::Mapping(_) => !next_is_index, + _ => false, + } +} + +/// Writes a value by path, returning a modified clone. +pub fn set_by_path(document: &Value, path: &str, value: Value) -> Result { + let tokens = parse_path(path)?; + let mut clone = document.clone(); + let Some((last, _)) = tokens.split_last() else { + return Err(PathError::Required); + }; + + let mut current = &mut clone; + for position in 0..tokens.len() - 1 { + let token = &tokens[position]; + let next_is_index = matches!(tokens[position + 1], PathToken::Index(_)); + let replacement = next_container(next_is_index); + current = match token { + PathToken::Key(key) => { + let Value::Mapping(mapping) = current else { + return Err(PathError::SetKeyOnNonObject(key.clone())); + }; + mapping + .entry(Value::String(key.clone())) + .and_modify(|existing| { + if !is_compatible(existing, next_is_index) { + *existing = replacement.clone(); + } + }) + .or_insert(replacement) + } + PathToken::Index(index) => { + let Value::Sequence(sequence) = current else { + return Err(PathError::IndexNonArray(*index)); + }; + ensure_array_length(sequence, *index); + let slot = &mut sequence[*index]; + if !is_compatible(slot, next_is_index) { + *slot = replacement; + } + slot + } + }; + } + + match last { + PathToken::Key(key) => { + let Value::Mapping(mapping) = current else { + return Err(PathError::SetKeyOnNonObject(key.clone())); + }; + mapping.insert(Value::String(key.clone()), value); + } + PathToken::Index(index) => { + let Value::Sequence(sequence) = current else { + return Err(PathError::IndexNonArray(*index)); + }; + ensure_array_length(sequence, *index); + sequence[*index] = value; + } + } + + Ok(clone) +} + +fn path_exists_in_schema(schema: &Value, tokens: &[PathToken]) -> bool { + let mut current = schema; + for token in tokens { + match token { + PathToken::Key(key) => { + let Value::Mapping(mapping) = current else { + return false; + }; + let Some(next) = mapping.get(key.as_str()) else { + return false; + }; + current = next; + } + PathToken::Index(_) => { + let Value::Sequence(sequence) = current else { + return false; + }; + if sequence.is_empty() { + return true; + } + current = &sequence[0]; + } + } + } + true +} + +/// Checks a path against the defaults document used as schema. +pub fn validate_path_against_defaults(defaults: &Value, path: &str) -> Result, PathError> { + let tokens = parse_path(path)?; + if path_exists_in_schema(defaults, &tokens) { + return Ok(Vec::new()); + } + Ok(vec![Issue::new( + path, + "Path does not exist in default settings schema", + )]) +} + +/// A value parsed from a string, keeping the distinction the capture keeps with `bigint`. +#[derive(Debug, Clone, PartialEq)] +pub enum ParsedValue { + Bool(bool), + Null, + Number(i64), + BigInt(String), + Json(Value), + String(String), +} + +impl ParsedValue { + /// Converts to a YAML value; a bigint becomes its decimal string, which is what + /// `toSerializable` does before saving. + pub fn to_value(&self) -> Value { + match self { + Self::Bool(boolean) => Value::Bool(*boolean), + Self::Null => Value::Null, + Self::Number(number) => Value::Number((*number).into()), + Self::BigInt(digits) | Self::String(digits) => Value::String(digits.clone()), + Self::Json(value) => value.clone(), + } + } +} + +/// Parses a string the way the settings editor's `inferred` mode does. +pub fn parse_value(raw: &str) -> ParsedValue { + let trimmed = raw.trim(); + match trimmed { + "true" => return ParsedValue::Bool(true), + "false" => return ParsedValue::Bool(false), + "null" => return ParsedValue::Null, + _ => {} + } + + if is_integer_literal(trimmed) + && let Ok(number) = trimmed.parse::() + && number.unsigned_abs() <= 9_007_199_254_740_991 + { + return ParsedValue::Number(number); + } + + if let Some(digits) = trimmed.strip_suffix('n') + && is_integer_literal(digits) + { + return ParsedValue::BigInt(digits.to_owned()); + } + + if ((trimmed.starts_with('{') && trimmed.ends_with('}')) + || (trimmed.starts_with('[') && trimmed.ends_with(']'))) + && let Ok(value) = serde_json::from_str::(trimmed) + { + return ParsedValue::Json(value); + } + + ParsedValue::String(raw.to_owned()) +} + +fn is_integer_literal(value: &str) -> bool { + let digits = value.strip_prefix('-').unwrap_or(value); + !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) +} + +/// Parses a string in the editor's `json` mode. +pub fn parse_typed_json(raw: &str) -> Result { + serde_json::from_str::(raw).map_err(|error| format!("Invalid JSON value: {error}")) +} diff --git a/crates/nostream-config/src/paths.rs b/crates/nostream-config/src/paths.rs new file mode 100644 index 000000000..d29324568 --- /dev/null +++ b/crates/nostream-config/src/paths.rs @@ -0,0 +1,41 @@ +//! Where the settings files live. Mirrors `src/utils/settings-paths.ts` exactly. + +use std::path::{Path, PathBuf}; + +/// The resolved settings paths for one process. +/// +/// `config_base_dir` is `NOSTR_CONFIG_DIR` when set, else `/.nostr`. `default_settings_file` +/// is `/resources/default-settings.yaml`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SettingsPaths { + pub config_base_dir: PathBuf, + pub settings_file: PathBuf, + pub legacy_settings_file: PathBuf, + pub default_settings_file: PathBuf, + pub backups_dir: PathBuf, + pub audit_log: PathBuf, +} + +impl SettingsPaths { + /// Resolves the paths from the process environment and the given working directory. + pub fn resolve(cwd: &Path) -> Self { + let base = match std::env::var_os("NOSTR_CONFIG_DIR") { + Some(value) => PathBuf::from(value), + None => cwd.join(".nostr"), + }; + Self::from_config_dir(cwd, base) + } + + /// Resolves the paths from an explicit config directory (the CLI's `--config-dir`). + pub fn from_config_dir(cwd: &Path, config_base_dir: impl Into) -> Self { + let config_base_dir = config_base_dir.into(); + Self { + settings_file: config_base_dir.join("settings.yaml"), + legacy_settings_file: config_base_dir.join("settings.json"), + default_settings_file: cwd.join("resources").join("default-settings.yaml"), + backups_dir: config_base_dir.join("backups"), + audit_log: config_base_dir.join("settings-audit.jsonl"), + config_base_dir, + } + } +} diff --git a/crates/nostream-config/src/redaction.rs b/crates/nostream-config/src/redaction.rs new file mode 100644 index 000000000..1f67c3d6c --- /dev/null +++ b/crates/nostream-config/src/redaction.rs @@ -0,0 +1,88 @@ +//! Secret classification and redaction. Mirrors `src/utils/settings-redaction.ts`. + +use serde_yaml::Value; + +/// Keys whose values are secrets wherever they appear. +const SENSITIVE_SETTING_KEYS: [&str; 3] = ["passwordHash", "secret", "botToken"]; + +/// The placeholder a redacted value becomes. +const REDACTED: &str = "***"; + +fn last_key(path: &str) -> &str { + let last = path.rsplit('.').next().unwrap_or(path); + if last.ends_with(']') + && let Some(bracket) = last.rfind('[') + { + return &last[..bracket]; + } + last +} + +/// Whether a settings path holds a secret. +/// +/// Webhook URLs embed secrets (Discord/Slack tokens, signed HTTP endpoints), so `url` under +/// `admin.notifications.targets` is sensitive too. +pub fn is_sensitive_settings_path(path: &str) -> bool { + let key = last_key(path); + if SENSITIVE_SETTING_KEYS.contains(&key) { + return true; + } + key == "url" && path.starts_with("admin.notifications.targets") +} + +/// Whether a path may not be written through the settings editor. +pub fn is_write_protected_settings_path(path: &str) -> bool { + path == "admin.passwordHash" || path.ends_with(".passwordHash") +} + +/// Redacts one value by path. +pub fn redact_settings_value(path: &str, value: &Value) -> Value { + match value { + Value::String(text) if is_sensitive_settings_path(path) && !text.is_empty() => { + Value::String(REDACTED.to_owned()) + } + other => other.clone(), + } +} + +/// Redacts every secret in a settings document, walking arrays and objects. +pub fn redact_settings_secrets(value: &Value) -> Value { + fn walk(value: &Value, path: &str) -> Value { + match value { + Value::Sequence(entries) => Value::Sequence( + entries + .iter() + .enumerate() + .map(|(index, entry)| walk(entry, &format!("{path}[{index}]"))) + .collect(), + ), + Value::Mapping(mapping) => { + let mut result = serde_yaml::Mapping::new(); + for (key, entry) in mapping { + let Some(key_name) = key.as_str() else { + result.insert(key.clone(), entry.clone()); + continue; + }; + let child_path = if path.is_empty() { + key_name.to_owned() + } else { + format!("{path}.{key_name}") + }; + let redacted = match entry { + Value::String(text) + if is_sensitive_settings_path(&child_path) && !text.is_empty() => + { + Value::String(REDACTED.to_owned()) + } + other => walk(other, &child_path), + }; + result.insert(key.clone(), redacted); + } + Value::Mapping(result) + } + other => other.clone(), + } + } + + walk(value, "") +} diff --git a/crates/nostream-config/src/settings.rs b/crates/nostream-config/src/settings.rs new file mode 100644 index 000000000..793f6c190 --- /dev/null +++ b/crates/nostream-config/src/settings.rs @@ -0,0 +1,737 @@ +//! The typed settings schema. Mirrors `src/@types/settings.ts` and +//! `src/@types/operator-notifications.ts` at the capture commit. +//! +//! Field names follow the YAML keys, not the TypeScript identifiers: sections carry +//! `camelCase` keys (`maxPayloadSize`, `feeSchedules`), `info.relay_url` and +//! `terms_of_service` are the snake_case exceptions, and the processor blocks spell +//! `baseURL` and `callbackBaseURL` with a capitalised acronym. +//! +//! Optional fields are `Option` and are skipped when serializing, so a typed value +//! round-trips to the document it was parsed from. There is no `deny_unknown_fields` here: +//! rejecting unknown keys is the shape validator's job (`crate::validate`), and the typed +//! parse runs only after validation. + +use nostream_core::{EventStoreBackend, Filter, KindSelector, Pubkey, Secret}; +use serde::{Deserialize, Serialize}; + +use crate::notifications::AdminNotificationsSettings; + +/// Serializes a JavaScript `number` as an integer when it is integral, so a document that +/// wrote `50` round-trips as `50` rather than `50.0`. YAML distinguishes the two even though +/// JavaScript does not. +fn serialize_js_number(value: &f64, serializer: S) -> Result +where + S: serde::Serializer, +{ + if value.is_finite() && value.fract() == 0.0 && value.abs() <= 9_007_199_254_740_991.0 { + serializer.serialize_i64(*value as i64) + } else { + serializer.serialize_f64(*value) + } +} + +/// `info` — the relay's NIP-11 identity (`src/@types/settings.ts` `Info`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Info { + pub relay_url: String, + pub name: String, + pub description: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub banner: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + pub pubkey: String, + #[serde(rename = "self", skip_serializing_if = "Option::is_none")] + pub self_: Option, + pub contact: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub terms_of_service: Option, +} + +/// `network` (`src/@types/settings.ts` `Network`). +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Network { + #[serde(skip_serializing_if = "Option::is_none")] + pub max_payload_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub remote_ip_header: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub trusted_proxies: Option>, +} + +/// One window of a rate limiter (`src/@types/settings.ts` `RateLimit`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RateLimit { + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub period: u64, + pub rate: u64, +} + +/// The rate-limiter strategy (`src/@types/settings.ts` `RateLimiterSettings`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RateLimiterStrategy { + Ewma, + SlidingWindow, +} + +/// The `limits.rateLimiter` block. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RateLimiterSettings { + pub strategy: RateLimiterStrategy, +} + +/// `limits.event.eventId` (`src/@types/settings.ts` `EventIdLimits`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EventIdLimits { + #[serde(skip_serializing_if = "Option::is_none")] + pub min_leading_zero_bits: Option, +} + +/// `limits.event.pubkey` (`src/@types/settings.ts` `PubkeyLimits`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PubkeyLimits { + /// `bigint` in the capture; `i64` in the rewrite (spec D15). + pub min_balance: i64, + pub min_leading_zero_bits: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub whitelist: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub blacklist: Option>, +} + +/// A rate limit with kind selectors (`src/@types/settings.ts` `EventRateLimit`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EventRateLimit { + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub kinds: Option>, + pub period: u64, + pub rate: u64, +} + +/// `limits.event.kind` (`src/@types/settings.ts` `KindLimits`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct KindLimits { + #[serde(skip_serializing_if = "Option::is_none")] + pub whitelist: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub blacklist: Option>, +} + +/// `limits.event.createdAt` (`src/@types/settings.ts` `CreatedAtLimits`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreatedAtLimits { + #[serde(skip_serializing_if = "Option::is_none")] + pub max_negative_delta: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_positive_delta: Option, +} + +/// One content-length rule (`src/@types/settings.ts` `ContentLimits`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContentLimits { + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub kinds: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_length: Option, +} + +/// `content` is one rule or a list of them (`ContentLimits | ContentLimits[]`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ContentLimitsOrVec { + One(ContentLimits), + Many(Vec), +} + +/// `limits.event.whitelists` (`src/@types/settings.ts` `EventWhitelists`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EventWhitelists { + #[serde(skip_serializing_if = "Option::is_none")] + pub pubkeys: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub ip_addresses: Option>, +} + +/// `limits.event.retention.kind` (`src/@types/settings.ts` `EventRetentionKindLimits`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct EventRetentionKindLimits { + #[serde(skip_serializing_if = "Option::is_none")] + pub whitelist: Option>, +} + +/// `limits.event.retention.pubkey` (`src/@types/settings.ts` `EventRetentionPubkeyLimits`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct EventRetentionPubkeyLimits { + #[serde(skip_serializing_if = "Option::is_none")] + pub whitelist: Option>, +} + +/// `limits.event.retention` (`src/@types/settings.ts` `EventRetentionLimits`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EventRetentionLimits { + #[serde(skip_serializing_if = "Option::is_none")] + pub max_days: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pubkey: Option, +} + +/// One WoT-distance difficulty reduction (`src/@types/settings.ts` `WotPowThreshold`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WotPowThreshold { + pub max_distance: u32, + #[serde(serialize_with = "serialize_js_number")] + pub difficulty_factor: f64, +} + +/// `limits.event.pow` (`src/@types/settings.ts` `AdaptivePowSettings`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdaptivePowSettings { + pub enabled: bool, + pub floor_bits: u32, + pub ceiling_bits: u32, + #[serde(serialize_with = "serialize_js_number")] + pub target_events_per_second: f64, + pub period_ms: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub wot_thresholds: Option>, +} + +/// `limits.event` (`src/@types/settings.ts` `EventLimits`). +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EventLimits { + #[serde(skip_serializing_if = "Option::is_none")] + pub event_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pubkey: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rate_limits: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub whitelists: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub retention: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pow: Option, +} + +/// `limits.client.subscription` (`src/@types/settings.ts` `ClientSubscriptionLimits`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientSubscriptionLimits { + #[serde(skip_serializing_if = "Option::is_none")] + pub max_subscriptions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_filters: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_filter_values: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub min_prefix_length: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_subscription_id_length: Option, +} + +/// `limits.client` (`src/@types/settings.ts` `ClientLimits`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientLimits { + #[serde(skip_serializing_if = "Option::is_none")] + pub subscription: Option, +} + +/// A message rate limit (`src/@types/settings.ts` `MessageRateLimit`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MessageRateLimit { + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub message_type: Option, + pub period: u64, + pub rate: u64, +} + +/// `limits.message` (`src/@types/settings.ts` `MessageLimits`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MessageLimits { + #[serde(skip_serializing_if = "Option::is_none")] + pub rate_limits: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub ip_whitelist: Option>, +} + +/// `limits.connection` (`src/@types/settings.ts` `ConnectionLimits`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectionLimits { + pub rate_limits: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub ip_whitelist: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub ip_blacklist: Option>, +} + +/// `limits.invoice` (`src/@types/settings.ts` `InvoiceLimits`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InvoiceLimits { + pub rate_limits: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub ip_whitelist: Option>, +} + +/// `limits.admissionCheck` (`src/@types/settings.ts` `AdmissionCheckLimits`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdmissionCheckLimits { + pub rate_limits: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub ip_whitelist: Option>, +} + +/// `limits.invite` (`src/@types/settings.ts` `InviteLimits`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InviteLimits { + #[serde(skip_serializing_if = "Option::is_none")] + pub rate_limits: Option>, +} + +/// `limits.admin` (`src/@types/settings.ts` `AdminLimits`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminLimits { + #[serde(skip_serializing_if = "Option::is_none")] + pub rate_limits: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub login_rate_limits: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub ip_whitelist: Option>, +} + +/// `limits` (`src/@types/settings.ts` `Limits`). +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Limits { + #[serde(skip_serializing_if = "Option::is_none")] + pub rate_limiter: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub invoice: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub invite: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub admission_check: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub admin: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub connection: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub client: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub event: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +/// `workers` (`src/@types/settings.ts` `Worker`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Worker { + pub count: u32, +} + +/// `eventStore` (`src/@types/settings.ts` `EventStoreSettings`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EventStoreSettings { + /// Read once at startup, so changing it needs a restart. + pub backend: EventStoreBackend, +} + +/// `payments.feeSchedules.*[].whitelists` (`src/@types/settings.ts` `FeeScheduleWhitelists`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct FeeScheduleWhitelists { + #[serde(skip_serializing_if = "Option::is_none")] + pub pubkeys: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_kinds: Option>, +} + +/// One fee schedule (`src/@types/settings.ts` `FeeSchedule`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FeeSchedule { + pub enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// `bigint` in the capture; `i64` in the rewrite (spec D15). + pub amount: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub whitelists: Option, +} + +/// `payments.feeSchedules` (`src/@types/settings.ts` `FeeSchedules`). +/// +/// `publication` is required in the capture's type but absent from the capture's own defaults, +/// so it is optional here: the shipped document is the schema the round-trip test pins. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FeeSchedules { + pub admission: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub publication: Option>, +} + +/// The payment processor key (`src/@types/settings.ts` `keyof PaymentsProcessors`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Processor { + Lnurl, + Zebedee, + Lnbits, + Nodeless, + Opennode, + Nwc, +} + +/// `payments` (`src/@types/settings.ts` `Payments`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Payments { + pub enabled: bool, + pub processor: Processor, + pub fee_schedules: FeeSchedules, + #[serde(skip_serializing_if = "Option::is_none")] + pub invoice_expiry_seconds: Option, +} + +/// `paymentsProcessors.lnurl` (`src/@types/settings.ts` `LnurlPaymentsProcessor`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LnurlPaymentsProcessor { + #[serde(rename = "invoiceURL")] + pub invoice_url: String, +} + +/// `paymentsProcessors.zebedee` (`src/@types/settings.ts` `ZebedeePaymentsProcessor`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ZebedeePaymentsProcessor { + #[serde(rename = "baseURL")] + pub base_url: String, + #[serde(rename = "callbackBaseURL")] + pub callback_base_url: String, + pub ip_whitelist: Vec, +} + +/// `paymentsProcessors.nodeless` (`src/@types/settings.ts` `NodelessPaymentsProcessor`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NodelessPaymentsProcessor { + #[serde(rename = "baseURL")] + pub base_url: String, + pub store_id: String, +} + +/// `paymentsProcessors.lnbits` (`src/@types/settings.ts` `LNbitsPaymentsProcessor`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LnbitsPaymentsProcessor { + #[serde(rename = "baseURL")] + pub base_url: String, + #[serde(rename = "callbackBaseURL")] + pub callback_base_url: String, +} + +/// `paymentsProcessors.opennode` (`src/@types/settings.ts` `OpenNodePaymentsProcessor`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpennodePaymentsProcessor { + #[serde(rename = "baseURL")] + pub base_url: String, + #[serde(rename = "callbackBaseURL")] + pub callback_base_url: String, +} + +/// `paymentsProcessors.nwc` (`src/@types/settings.ts` `NwcPaymentsProcessor`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NwcPaymentsProcessor { + pub invoice_expiry_seconds: u64, + pub reply_timeout_ms: u64, +} + +/// `paymentsProcessors` (`src/@types/settings.ts` `PaymentsProcessors`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentsProcessors { + #[serde(skip_serializing_if = "Option::is_none")] + pub lnurl: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub zebedee: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub lnbits: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nodeless: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub opennode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nwc: Option, +} + +/// A mirror whose events are replicated (`src/@types/settings.ts` `Mirror`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Mirror { + pub address: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub filters: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub secret: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limits: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub skip_admission_check: Option, +} + +/// `mirroring.static[].limits` (`src/@types/settings.ts` inline `limits`). +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct MirrorLimits { + #[serde(skip_serializing_if = "Option::is_none")] + pub event: Option, +} + +/// `mirroring` (`src/@types/settings.ts` `Mirroring`). +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Mirroring { + #[serde(rename = "static", skip_serializing_if = "Option::is_none")] + pub static_: Option>, +} + +/// One DVM worker (`src/@types/settings.ts` `DvmWorker`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DvmWorker { + pub command: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub args: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub kinds: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option, +} + +/// `dvm` (`src/@types/settings.ts` `Dvm`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Dvm { + #[serde(skip_serializing_if = "Option::is_none")] + pub workers: Option>, +} + +/// `nip05.mode` (`src/@types/settings.ts` `Nip05Mode`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Nip05Mode { + Enabled, + Passive, + Disabled, +} + +/// `nip45` (`src/@types/settings.ts` `Nip45Settings`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Nip45Settings { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} + +/// `nip50` (`src/@types/settings.ts` `Nip50Settings`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Nip50Settings { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub language: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_query_length: Option, +} + +/// `nip66.timeouts` (`src/@types/settings.ts` `Nip66ProbeTimeouts`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Nip66ProbeTimeouts { + pub dns_ms: u64, + pub tls_ms: u64, + pub ws_rtt_ms: u64, + pub nip11_ms: u64, +} + +/// `nip66` (`src/@types/settings.ts` `Nip66Settings`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Nip66Settings { + pub enabled: bool, + pub probe_interval_seconds: u64, + pub timeouts: Nip66ProbeTimeouts, + pub targets: Vec, + pub dns_cache_ttl_seconds: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub geohash: Option, +} + +/// `nip05` (`src/@types/settings.ts` `Nip05Settings`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Nip05Settings { + pub mode: Nip05Mode, + #[serde(skip_serializing_if = "Option::is_none")] + pub verify_expiration: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub verify_update_frequency: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_consecutive_failures: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub domain_whitelist: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub domain_blacklist: Option>, +} + +/// `admin.nip98` (`src/@types/settings.ts` `AdminNip98Settings`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminNip98Settings { + pub enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub allowed_pubkeys: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_skew_seconds: Option, +} + +/// `admin` (`src/@types/settings.ts` `AdminSettings`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminSettings { + pub enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub password_hash: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_ttl_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nip98: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub notifications: Option, +} + +/// `wot` (`src/@types/settings.ts` `WoTSettings`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WotSettings { + pub enabled: bool, + pub seed_pubkey: Pubkey, + pub minimum_followers: u32, + pub max_depth: u32, + pub refresh_interval_hours: u64, +} + +/// `nip42.restrictedReads` (`src/@types/settings.ts` `Nip42RestrictedReads`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Nip42RestrictedReads { + pub enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub kinds: Option>, +} + +/// `nip42` (`src/@types/settings.ts` `Nip42Settings`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Nip42Settings { + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_required: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_expiry_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub restricted_reads: Option, +} + +/// `nip43` (`src/@types/settings.ts` `Nip43Settings`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Nip43Settings { + pub enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub invite_code_expiry_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_max_uses: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub allow_invite_requests: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub invite_request_whitelist: Option>, +} + +/// `nip56` (`src/@types/settings.ts` `Nip56Settings`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Nip56Settings { + pub enabled: bool, + pub trusted_moderators: Vec, + pub hide_actionable_reports: bool, +} + +/// The root settings document (`src/@types/settings.ts` `Settings`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Settings { + pub info: Info, + #[serde(skip_serializing_if = "Option::is_none")] + pub admin: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub payments: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub payments_processors: Option, + pub network: Network, + #[serde(skip_serializing_if = "Option::is_none")] + pub workers: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_store: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limits: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mirroring: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dvm: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nip05: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nip42: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nip43: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nip45: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nip50: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nip56: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nip66: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub wot: Option, +} diff --git a/crates/nostream-config/src/validate.rs b/crates/nostream-config/src/validate.rs new file mode 100644 index 000000000..165b14c38 --- /dev/null +++ b/crates/nostream-config/src/validate.rs @@ -0,0 +1,467 @@ +//! The settings validator. Mirrors `src/utils/settings-config.ts` `validateSettings` and +//! `validateShape` at the capture commit, including issue order and message text. +//! +//! Order is part of the contract: the semantic rules run first, then the defaults document is +//! walked as a schema, then the admin-notification rules. Two behaviours are load-bearing: +//! unknown keys are rejected at every depth, and the shipped defaults are the schema, so the +//! defaults file is part of the contract rather than just data. + +use nostream_core::EventStoreBackend; +use serde_yaml::Value; + +use crate::error::ConfigError; +use crate::issue::Issue; +use crate::path::{PathToken, format_path_tokens}; +use crate::settings::Settings; +use crate::value::{ + compare_le, is_nil, is_plain_object, js_typeof, key, key_path, merge_deep_right, number_ge, + number_le, number_lt, to_number, truthy_opt, +}; + +/// Validates a settings document against a defaults document. +/// +/// `candidate` is the merged settings (defaults deep-merged with the user's overrides); +/// `defaults` is the shipped defaults document, used as the schema. +pub fn validate_document(candidate: &Value, defaults: &Value) -> Vec { + let mut issues = Vec::new(); + + if !truthy_opt(key_path(candidate, &["info", "relay_url"])) { + issues.push(Issue::new("info.relay_url", "relay_url is required")); + } + + if !truthy_opt(key_path(candidate, &["info", "name"])) { + issues.push(Issue::new("info.name", "name is required")); + } + + if !truthy_opt(key(candidate, "network")) { + issues.push(Issue::new("network", "network section is required")); + } + + if truthy_opt(key_path(candidate, &["payments", "enabled"])) + && !truthy_opt(key_path(candidate, &["payments", "processor"])) + { + issues.push(Issue::new( + "payments.processor", + "processor is required when payments are enabled", + )); + } + + let strategy = key_path(candidate, &["limits", "rateLimiter", "strategy"]); + if truthy_opt(strategy) && !matches!(strategy, Some(Value::String(name)) if name == "ewma" || name == "sliding_window") + { + issues.push(Issue::new( + "limits.rateLimiter.strategy", + "strategy must be ewma or sliding_window", + )); + } + + let backend = key_path(candidate, &["eventStore", "backend"]); + if !is_nil(backend) && !is_event_store_backend(backend) { + let names = EventStoreBackend::ALL + .iter() + .map(|backend| backend.as_str()) + .collect::>() + .join(", "); + issues.push(Issue::new( + "eventStore.backend", + format!("backend must be one of: {names}"), + )); + } + + if let Some(pow) = key_path(candidate, &["limits", "event", "pow"]) + && truthy_opt(key(pow, "enabled")) + { + let floor_bits = key(pow, "floorBits"); + let ceiling_bits = key(pow, "ceilingBits"); + + if !number_ge(floor_bits, 0.0) || !compare_le(floor_bits, ceiling_bits) { + issues.push(Issue::new( + "limits.event.pow.floorBits", + "floorBits must be >= 0 and <= ceilingBits", + )); + } + if !number_le(ceiling_bits, 256.0) { + issues.push(Issue::new( + "limits.event.pow.ceilingBits", + "ceilingBits must be <= 256", + )); + } + if !to_number_is_positive(key(pow, "periodMs")) { + issues.push(Issue::new( + "limits.event.pow.periodMs", + "periodMs must be greater than 0", + )); + } + if !to_number_is_positive(key(pow, "targetEventsPerSecond")) { + issues.push(Issue::new( + "limits.event.pow.targetEventsPerSecond", + "targetEventsPerSecond must be greater than 0", + )); + } + + if let Some(Value::Sequence(thresholds)) = key(pow, "wotThresholds") { + for (index, threshold) in thresholds.iter().enumerate() { + if !matches!(threshold, Value::Mapping(_)) { + issues.push(Issue::new( + format!("limits.event.pow.wotThresholds[{index}]"), + "wotThresholds entry must be an object", + )); + continue; + } + if !number_ge(key(threshold, "maxDistance"), 0.0) { + issues.push(Issue::new( + format!("limits.event.pow.wotThresholds[{index}].maxDistance"), + "maxDistance must be >= 0", + )); + } + let factor = key(threshold, "difficultyFactor"); + if !number_ge(factor, 0.0) || !number_le(factor, 1.0) { + issues.push(Issue::new( + format!("limits.event.pow.wotThresholds[{index}].difficultyFactor"), + "difficultyFactor must be between 0 and 1", + )); + } + } + } + } + + if let Some(limits) = key(candidate, "limits") { + validate_rate_limit_periods(key_path(limits, &["event", "rateLimits"]), "limits.event.rateLimits", &mut issues); + validate_rate_limit_periods(key_path(limits, &["message", "rateLimits"]), "limits.message.rateLimits", &mut issues); + validate_rate_limit_periods(key_path(limits, &["connection", "rateLimits"]), "limits.connection.rateLimits", &mut issues); + validate_rate_limit_periods(key_path(limits, &["invoice", "rateLimits"]), "limits.invoice.rateLimits", &mut issues); + validate_rate_limit_periods( + key_path(limits, &["admissionCheck", "rateLimits"]), + "limits.admissionCheck.rateLimits", + &mut issues, + ); + validate_rate_limit_periods(key_path(limits, &["invite", "rateLimits"]), "limits.invite.rateLimits", &mut issues); + validate_rate_limit_periods(key_path(limits, &["admin", "rateLimits"]), "limits.admin.rateLimits", &mut issues); + validate_rate_limit_periods( + key_path(limits, &["admin", "loginRateLimits"]), + "limits.admin.loginRateLimits", + &mut issues, + ); + } + + validate_shape(defaults, Some(candidate), &mut Vec::new(), &mut issues); + issues.extend(validate_admin_notifications(candidate)); + + issues +} + +fn to_number_is_positive(value: Option<&Value>) -> bool { + value.and_then(to_number).is_some_and(|number| number > 0.0) +} + +fn is_event_store_backend(value: Option<&Value>) -> bool { + matches!(value, Some(Value::String(name)) if EventStoreBackend::parse(name).is_some()) +} + +fn validate_rate_limit_periods(rate_limits: Option<&Value>, path_prefix: &str, issues: &mut Vec) { + let Some(Value::Sequence(entries)) = rate_limits else { + return; + }; + for (index, entry) in entries.iter().enumerate() { + if !is_plain_object(entry) { + issues.push(Issue::new( + format!("{path_prefix}[{index}]"), + "rateLimits entry must be an object", + )); + continue; + } + if !to_number_is_positive(key(entry, "period")) { + issues.push(Issue::new( + format!("{path_prefix}[{index}].period"), + "period must be greater than 0", + )); + } + } +} + +/// Walks the defaults document as the schema for the candidate. +fn validate_shape( + schema: &Value, + candidate: Option<&Value>, + path: &mut Vec, + issues: &mut Vec, +) { + let Some(candidate) = candidate else { + return; + }; + + let rendered = { + let rendered = format_path_tokens(path); + if rendered.is_empty() { + "$".to_owned() + } else { + rendered + } + }; + + match schema { + Value::Sequence(schema_entries) => { + let Value::Sequence(candidates) = candidate else { + issues.push(Issue::new( + rendered, + format!("Expected array, got {}", js_typeof(candidate)), + )); + return; + }; + + if schema_entries.is_empty() { + return; + } + + for (index, entry) in candidates.iter().enumerate() { + let matches_any = schema_entries.iter().any(|schema_entry| { + let mut local_issues = Vec::new(); + let mut local_path = path.clone(); + local_path.push(PathToken::Index(index)); + validate_shape(schema_entry, Some(entry), &mut local_path, &mut local_issues); + local_issues.is_empty() + }); + + if !matches_any { + let mut issue_path = path.clone(); + issue_path.push(PathToken::Index(index)); + issues.push(Issue::new( + format_path_tokens(&issue_path), + "Array element does not match expected schema shape", + )); + } + } + } + Value::Mapping(schema_mapping) => { + let Value::Mapping(candidates) = candidate else { + issues.push(Issue::new( + rendered, + format!("Expected object, got {}", js_typeof(candidate)), + )); + return; + }; + + for key in candidates.keys() { + let Some(key) = key.as_str() else { + continue; + }; + if rendered == "admin" && key == "passwordHash" { + continue; + } + if !schema_mapping.contains_key(key) { + let mut issue_path = path.clone(); + issue_path.push(PathToken::Key(key.to_owned())); + issues.push(Issue::new(format_path_tokens(&issue_path), "Unknown setting key")); + } + } + + for (key, schema_value) in schema_mapping { + let Some(key) = key.as_str() else { + continue; + }; + let mut child_path = path.clone(); + child_path.push(PathToken::Key(key.to_owned())); + validate_shape(schema_value, candidates.get(key), &mut child_path, issues); + } + } + _ => { + if matches!(candidate, Value::Null) && !matches!(schema, Value::Null) { + issues.push(Issue::new( + rendered, + format!("Expected {}, got null", js_typeof(schema)), + )); + return; + } + if !matches!(schema, Value::Null) && js_typeof(schema) != js_typeof(candidate) { + issues.push(Issue::new( + rendered, + format!("Expected {}, got {}", js_typeof(schema), js_typeof(candidate)), + )); + } + } + } +} + +fn validate_admin_notifications(candidate: &Value) -> Vec { + let mut issues = Vec::new(); + let Some(notifications) = key_path(candidate, &["admin", "notifications"]) else { + return issues; + }; + + let retry = key(notifications, "retry"); + if let Some(max_attempts) = retry.and_then(|retry| key(retry, "maxAttempts")) + && number_lt(Some(max_attempts), 1.0) + { + issues.push(Issue::new( + "admin.notifications.retry.maxAttempts", + "maxAttempts must be at least 1", + )); + } + if let Some(base_delay) = retry.and_then(|retry| key(retry, "baseDelayMs")) + && number_lt(Some(base_delay), 0.0) + { + issues.push(Issue::new( + "admin.notifications.retry.baseDelayMs", + "baseDelayMs must be >= 0", + )); + } + if let Some(retention) = key(notifications, "deliveryLogRetentionDays") + && number_lt(Some(retention), 1.0) + { + issues.push(Issue::new( + "admin.notifications.deliveryLogRetentionDays", + "deliveryLogRetentionDays must be at least 1", + )); + } + + let raw_targets = key(notifications, "targets"); + if let Some(targets) = raw_targets + && !matches!(targets, Value::Sequence(_)) + { + issues.push(Issue::new( + "admin.notifications.targets", + "targets must be an array", + )); + return issues; + } + + let targets: &[Value] = match raw_targets { + Some(Value::Sequence(entries)) => entries.as_slice(), + _ => &[], + }; + + let mut target_ids: Vec = Vec::new(); + for (index, target) in targets.iter().enumerate() { + let prefix = format!("admin.notifications.targets[{index}]"); + if !matches!(target, Value::Mapping(_)) { + issues.push(Issue::new(prefix, "target must be an object")); + continue; + } + + match key(target, "id") { + Some(Value::String(id)) if !id.trim().is_empty() => { + if target_ids.iter().any(|existing| existing == id) { + issues.push(Issue::new(format!("{prefix}.id"), "target id must be unique")); + } else { + target_ids.push(id.clone()); + } + } + _ => issues.push(Issue::new(format!("{prefix}.id"), "target id is required")), + } + + let target_type = match key(target, "type") { + Some(Value::String(name)) + if matches!(name.as_str(), "http" | "discord" | "slack" | "telegram") => + { + Some(name.as_str()) + } + _ => { + issues.push(Issue::new( + format!("{prefix}.type"), + "type must be http, discord, slack, or telegram", + )); + None + } + }; + + if let Some(enabled) = key(target, "enabled") + && !matches!(enabled, Value::Bool(_)) + { + issues.push(Issue::new(format!("{prefix}.enabled"), "enabled must be a boolean")); + } + + match target_type { + Some("telegram") => { + if !is_non_blank_string(key(target, "botToken")) { + issues.push(Issue::new( + format!("{prefix}.botToken"), + "botToken is required for telegram targets", + )); + } + if !is_non_blank_string(key(target, "chatId")) { + issues.push(Issue::new( + format!("{prefix}.chatId"), + "chatId is required for telegram targets", + )); + } + } + Some(_) => match key(target, "url") { + Some(Value::String(url)) if !url.trim().is_empty() => { + match check_webhook_url(url) { + UrlCheck::Ok => {} + UrlCheck::BadProtocol => issues.push(Issue::new( + format!("{prefix}.url"), + "url must use http or https", + )), + UrlCheck::Invalid => { + issues.push(Issue::new(format!("{prefix}.url"), "url must be a valid URL")) + } + } + } + _ => issues.push(Issue::new( + format!("{prefix}.url"), + "url is required for webhook targets", + )), + }, + None => {} + } + } + + issues +} + +fn is_non_blank_string(value: Option<&Value>) -> bool { + matches!(value, Some(Value::String(text)) if !text.trim().is_empty()) +} + +enum UrlCheck { + Ok, + BadProtocol, + Invalid, +} + +fn check_webhook_url(raw: &str) -> UrlCheck { + let trimmed = raw.trim(); + let Some(colon) = trimmed.find(':') else { + return UrlCheck::Invalid; + }; + let scheme = &trimmed[..colon]; + let mut characters = scheme.chars(); + let valid_scheme = matches!(characters.next(), Some(first) if first.is_ascii_alphabetic()) + && characters.all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.') + }); + if !valid_scheme { + return UrlCheck::Invalid; + } + + let scheme = scheme.to_ascii_lowercase(); + if scheme != "http" && scheme != "https" { + return UrlCheck::BadProtocol; + } + + let Some(authority) = trimmed[colon + 1..].strip_prefix("//") else { + return UrlCheck::Invalid; + }; + let host = authority + .split(['/', '?', '#']) + .next() + .unwrap_or_default(); + if host.is_empty() { + return UrlCheck::Invalid; + } + + UrlCheck::Ok +} + +/// Validates the typed settings by serializing them back to a document, so there is exactly +/// one validation algorithm. +pub fn validate(settings: &Settings, defaults: &Value) -> Result, ConfigError> { + let candidate = serde_yaml::to_value(settings)?; + Ok(validate_document(&candidate, defaults)) +} + +/// Deep-merges a user document over the defaults (the loader's step before validation). +pub fn merge_document(defaults: &Value, user: &Value) -> Value { + merge_deep_right(defaults, user) +} diff --git a/crates/nostream-config/src/value.rs b/crates/nostream-config/src/value.rs new file mode 100644 index 000000000..3818dec9a --- /dev/null +++ b/crates/nostream-config/src/value.rs @@ -0,0 +1,149 @@ +//! JavaScript semantics over `serde_yaml::Value`. +//! +//! The validator's messages and its decisions are the capture's, and the capture runs on +//! JavaScript values: `typeof` names arrays `object`, truthiness accepts `[]` and `{}`, and a +//! comparison with `undefined` is `false`. These helpers keep those rules in one place so the +//! port does not quietly become Rust semantics. + +use serde_yaml::{Mapping, Value}; + +/// Whether the value is a plain object (`typeof v === 'object' && v !== null && !Array.isArray(v)`). +pub fn is_plain_object(value: &Value) -> bool { + matches!(value, Value::Mapping(_)) +} + +/// JavaScript truthiness: `false`, `0`, `""`, `null` and `undefined` are false; everything +/// else (including `[]` and `{}`) is true. +pub fn truthy(value: &Value) -> bool { + match value { + Value::Null => false, + Value::Bool(boolean) => *boolean, + Value::Number(number) => number.as_f64().is_some_and(|float| float != 0.0 && !float.is_nan()), + Value::String(string) => !string.is_empty(), + Value::Sequence(_) | Value::Mapping(_) | Value::Tagged(_) => true, + } +} + +/// Truthiness of an optional value (`undefined` is false). +pub fn truthy_opt(value: Option<&Value>) -> bool { + value.is_some_and(truthy) +} + +/// The result of JavaScript `typeof value`. +pub fn js_typeof(value: &Value) -> &'static str { + match value { + Value::Null => "object", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Sequence(_) | Value::Mapping(_) | Value::Tagged(_) => "object", + } +} + +/// JavaScript `Number(value)`; `None` stands for `NaN`. +pub fn to_number(value: &Value) -> Option { + match value { + Value::Null => Some(0.0), + Value::Bool(boolean) => Some(if *boolean { 1.0 } else { 0.0 }), + Value::Number(number) => number.as_f64(), + Value::String(string) => { + let trimmed = string.trim(); + if trimmed.is_empty() { + return Some(0.0); + } + if let Some(hex) = trimmed + .strip_prefix("0x") + .or_else(|| trimmed.strip_prefix("0X")) + { + return u64::from_str_radix(hex, 16).ok().map(|value| value as f64); + } + trimmed.parse::().ok().filter(|float| !float.is_nan()) + } + Value::Sequence(_) | Value::Mapping(_) | Value::Tagged(_) => None, + } +} + +/// `value >= bound`, with `undefined` and non-numeric values comparing false. +pub fn number_ge(value: Option<&Value>, bound: f64) -> bool { + value.and_then(to_number).is_some_and(|number| number >= bound) +} + +/// `value <= bound`, with `undefined` and non-numeric values comparing false. +pub fn number_le(value: Option<&Value>, bound: f64) -> bool { + value.and_then(to_number).is_some_and(|number| number <= bound) +} + +/// `left >= right`, with either side missing or non-numeric comparing false. +pub fn compare_ge(left: Option<&Value>, right: Option<&Value>) -> bool { + match (left.and_then(to_number), right.and_then(to_number)) { + (Some(left), Some(right)) => left >= right, + _ => false, + } +} + +/// `left <= right`, with either side missing or non-numeric comparing false. +pub fn compare_le(left: Option<&Value>, right: Option<&Value>) -> bool { + match (left.and_then(to_number), right.and_then(to_number)) { + (Some(left), Some(right)) => left <= right, + _ => false, + } +} + +/// `value < bound`. +pub fn number_lt(value: Option<&Value>, bound: f64) -> bool { + value.and_then(to_number).is_some_and(|number| number < bound) +} + +/// A property read (`value.name`); `None` means `undefined`. +pub fn key<'a>(value: &'a Value, name: &str) -> Option<&'a Value> { + match value { + Value::Mapping(mapping) => mapping.get(name), + _ => None, + } +} + +/// A chain of property reads. +pub fn key_path<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> { + let mut current = value; + for name in path { + current = key(current, name)?; + } + Some(current) +} + +/// Whether a value is `null` or absent (`isNil`). +pub fn is_nil(value: Option<&Value>) -> bool { + match value { + None | Some(Value::Null) => true, + Some(_) => false, + } +} + +/// Ramda's `mergeDeepRight`: objects merge recursively, arrays and scalars are replaced by +/// the right-hand value, and `null` is a value that replaces. +pub fn merge_deep_right(left: &Value, right: &Value) -> Value { + match (left, right) { + (Value::Mapping(left), Value::Mapping(right)) => { + let mut merged = left.clone(); + for (key, right_value) in right { + let value = match merged.get(key) { + Some(left_value) => merge_deep_right(left_value, right_value), + None => right_value.clone(), + }; + merged.insert(key.clone(), value); + } + Value::Mapping(merged) + } + _ => right.clone(), + } +} + +/// An empty YAML mapping. +pub fn empty_mapping() -> Value { + Value::Mapping(Mapping::new()) +} + +/// A YAML string value. +pub fn string_value(value: impl Into) -> Value { + Value::String(value.into()) +} diff --git a/crates/nostream-config/tests/backend_values.rs b/crates/nostream-config/tests/backend_values.rs new file mode 100644 index 000000000..a627a1d1b --- /dev/null +++ b/crates/nostream-config/tests/backend_values.rs @@ -0,0 +1,52 @@ +//! `eventStore.backend` accepts the rewrite's LMDB backend (spec D7) and still rejects +//! anything outside the two-member set, with the two-member message. + +use std::fs; + +use nostream_config::path::set_by_path; +use nostream_config::validate_document; +use serde_yaml::Value; + +fn defaults() -> Value { + let raw = fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../resources/default-settings.yaml" + )) + .expect("the shipped defaults are readable"); + serde_yaml::from_str(&raw).expect("the shipped defaults are valid YAML") +} + +fn with_backend(backend: &str) -> Vec { + let defaults = defaults(); + let document = set_by_path( + &defaults, + "eventStore.backend", + Value::String(backend.to_owned()), + ) + .expect("the path is writable"); + validate_document(&document, &defaults) + .into_iter() + .filter(|issue| issue.path.starts_with("eventStore")) + .collect() +} + +#[test] +fn postgres_is_accepted() { + assert_eq!(with_backend("postgres"), Vec::new()); +} + +#[test] +fn lmdb_is_accepted() { + assert_eq!(with_backend("lmdb"), Vec::new()); +} + +#[test] +fn an_unknown_backend_is_rejected_with_both_members_named() { + assert_eq!( + with_backend("sqlite"), + vec![nostream_config::Issue::new( + "eventStore.backend", + "backend must be one of: postgres, lmdb" + )] + ); +} diff --git a/crates/nostream-config/tests/capture/gen-ts-issues.mjs b/crates/nostream-config/tests/capture/gen-ts-issues.mjs new file mode 100644 index 000000000..8a5811251 --- /dev/null +++ b/crates/nostream-config/tests/capture/gen-ts-issues.mjs @@ -0,0 +1,134 @@ +// Regenerates the `cases` array of ../fixtures/ts-issues.json from the TypeScript validator. +// +// Subject: cameri/nostream at 242a3001d77149e4d70151ec03060c4ef44a1547 — the capture commit +// this branch is cut from. The harness MUST run with that tree as its working directory, +// because loadDefaults() reads /resources/default-settings.yaml. +// +// One-time setup, in a scratch directory (never in the working tree): +// git archive 242a3001 | tar -x -C /tmp/nostream-capture +// cd /tmp/nostream-capture && pnpm install --frozen-lockfile +// +// Run, from the capture tree, with this file's absolute path: +// cd /tmp/nostream-capture +// node > /tmp/ts-issues.cases.json +// +// Merge the printed array into `cases` and leave `subject`/`deferred` untouched: those are +// metadata about this job, not measurements. The cases below are the probe statements the job +// grades; none may be removed or renamed without regenerating and stating why. +import { createRequire } from 'node:module' +import { existsSync } from 'node:fs' +import path from 'node:path' + +const cwd = process.cwd() +const settingsModule = path.join(cwd, 'src/utils/settings-config.ts') +if (!existsSync(settingsModule) || !existsSync(path.join(cwd, 'resources/default-settings.yaml'))) { + console.error(`run this from the capture tree (cwd=${cwd}); src/utils/settings-config.ts or resources/default-settings.yaml is missing`) + process.exit(2) +} + +const require = createRequire(import.meta.url) +require('ts-node/register/transpile-only') +const { loadDefaults, setByPath, validateSettings } = require(settingsModule) + +const deleteByPath = (document, dotted) => { + const segments = dotted.split('.') + const last = segments.pop() + const parentPath = segments + .join('.') + .replace(/\[(\d+)\]/g, '.$1') + const parent = parentPath ? parentPath.split('.').reduce((node, key) => node?.[key], document) : document + if (parent && typeof parent === 'object') { + delete parent[last] + } + return document +} + +const CASES = [ + { name: 'defaults', mutations: [] }, + { name: 'backend_lmdb', mutations: [{ op: 'set', path: 'eventStore.backend', value: 'lmdb' }] }, + { + name: 'pow_floor_gt_ceiling', + mutations: [ + { op: 'set', path: 'limits.event.pow.enabled', value: true }, + { op: 'set', path: 'limits.event.pow.floorBits', value: 16 }, + { op: 'set', path: 'limits.event.pow.ceilingBits', value: 8 }, + ], + }, + { + name: 'pow_period_zero', + mutations: [ + { op: 'set', path: 'limits.event.pow.enabled', value: true }, + { op: 'set', path: 'limits.event.pow.periodMs', value: 0 }, + ], + }, + { + name: 'pow_target_zero', + mutations: [ + { op: 'set', path: 'limits.event.pow.enabled', value: true }, + { op: 'set', path: 'limits.event.pow.targetEventsPerSecond', value: 0 }, + ], + }, + { + name: 'conn_rate_limit_period_zero', + mutations: [{ op: 'set', path: 'limits.connection.rateLimits[0].period', value: 0 }], + }, + { + name: 'telegram_missing_creds', + mutations: [{ op: 'set', path: 'admin.notifications.targets', value: [{ id: 't1', type: 'telegram', enabled: true }] }], + }, + { + name: 'duplicate_target_id', + mutations: [ + { + op: 'set', + path: 'admin.notifications.targets', + value: [ + { id: 'dup', type: 'http', enabled: true, url: 'https://a.example' }, + { id: 'dup', type: 'http', enabled: true, url: 'https://b.example' }, + ], + }, + ], + }, + { + name: 'target_bad_url', + mutations: [{ op: 'set', path: 'admin.notifications.targets', value: [{ id: 'w1', type: 'http', url: 'ftp://bad.example' }] }], + }, + { name: 'unknown_top_level', mutations: [{ op: 'set', path: 'bogusSection', value: {} }] }, + { name: 'unknown_nested_key', mutations: [{ op: 'set', path: 'info.unknownKey', value: 1 }] }, + { name: 'wrong_type_workers_count', mutations: [{ op: 'set', path: 'workers.count', value: 'four' }] }, + { name: 'missing_name', mutations: [{ op: 'delete', path: 'info.name' }] }, + { name: 'missing_relay_url', mutations: [{ op: 'delete', path: 'info.relay_url' }] }, + { name: 'bad_strategy', mutations: [{ op: 'set', path: 'limits.rateLimiter.strategy', value: 'random' }] }, + { name: 'nip77_present', mutations: [{ op: 'set', path: 'nip77', value: { enabled: true } }] }, + { name: 'relay_url_ftp', mutations: [{ op: 'set', path: 'info.relay_url', value: 'ftp://relay.example.com' }] }, +] + +const EXEMPT = { + backend_lmdb: { + paths: ['eventStore.backend'], + reason: + 'D7: the rewrite targets LMDB, so the Rust validator accepts lmdb and rejects values outside {postgres, lmdb}. Path-scoped, not case-scoped.', + }, +} + +const output = CASES.map((entry) => { + let settings = structuredClone(loadDefaults()) + for (const mutation of entry.mutations) { + settings = + mutation.op === 'set' + ? setByPath(settings, mutation.path, structuredClone(mutation.value)) + : deleteByPath(settings, mutation.path) + } + + const issues = validateSettings(settings).map(({ path: issuePath, message }) => ({ path: issuePath, message })) + const exemption = EXEMPT[entry.name] + return { + name: entry.name, + mutations: entry.mutations, + ts_issues: issues, + rust_exempt_paths: exemption ? exemption.paths : [], + ...(exemption ? { exempt_reason: exemption.reason } : {}), + } +}) + +console.log(JSON.stringify(output, null, 2)) diff --git a/crates/nostream-config/tests/defaults_parity.rs b/crates/nostream-config/tests/defaults_parity.rs new file mode 100644 index 000000000..c5297834b --- /dev/null +++ b/crates/nostream-config/tests/defaults_parity.rs @@ -0,0 +1,161 @@ +//! Defaults parity with `test/unit/utils/settings.spec.ts:226-341` at the capture commit: +//! six defaults assertions and four override cases, value for value. + +use std::fs; + +use nostream_config::load::merge; +use nostream_config::path::get_by_path; +use serde_yaml::Value; + +fn defaults() -> Value { + let raw = fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../resources/default-settings.yaml" + )) + .expect("the shipped defaults are readable"); + serde_yaml::from_str(&raw).expect("the shipped defaults are valid YAML") +} + +fn value(document: &Value, path: &str) -> Value { + get_by_path(document, path) + .expect("the path parses") + .unwrap_or_else(|| panic!("{path} exists")) + .clone() +} + +fn assert_value(document: &Value, path: &str, expected: Value) { + assert_eq!(value(document, path), expected, "{path}"); +} + +fn boolean(boolean: bool) -> Value { + Value::Bool(boolean) +} + +fn number(number: u64) -> Value { + Value::Number(number.into()) +} + +#[test] +fn nip43_defaults_are_the_capture_mint_defaults() { + let defaults = defaults(); + assert_value(&defaults, "nip43.enabled", boolean(false)); + assert_value(&defaults, "nip43.inviteCodeExpirySeconds", number(600)); + assert_value(&defaults, "nip43.defaultMaxUses", number(1)); + assert_value(&defaults, "nip43.allowInviteRequests", boolean(false)); + assert_value(&defaults, "nip43.inviteRequestWhitelist", Value::Sequence(vec![])); +} + +#[test] +fn nip43_invite_rate_limit_is_per_pubkey() { + let defaults = defaults(); + let rate_limits = get_by_path(&defaults, "limits.invite.rateLimits") + .expect("path parses") + .expect("the list exists"); + let Value::Sequence(entries) = rate_limits else { + panic!("limits.invite.rateLimits is a sequence"); + }; + assert_eq!(entries.len(), 1); + assert_value(&defaults, "limits.invite.rateLimits[0].period", number(3_600_000)); + assert_value(&defaults, "limits.invite.rateLimits[0].rate", number(5)); +} + +#[test] +fn info_self_is_left_unset_so_it_is_derived() { + let defaults = defaults(); + assert!( + get_by_path(&defaults, "info.self") + .expect("path parses") + .is_none(), + "info.self must not be shipped; it is derived from SECRET" + ); +} + +#[test] +fn nip66_defaults_are_safe() { + let defaults = defaults(); + assert_value(&defaults, "nip66.enabled", boolean(false)); + assert_value(&defaults, "nip66.probeIntervalSeconds", number(3600)); + assert_value(&defaults, "nip66.timeouts.dnsMs", number(10_000)); + assert_value(&defaults, "nip66.timeouts.tlsMs", number(10_000)); + assert_value(&defaults, "nip66.timeouts.wsRttMs", number(10_000)); + assert_value(&defaults, "nip66.timeouts.nip11Ms", number(10_000)); + assert_value(&defaults, "nip66.targets", Value::Sequence(vec![])); + assert_value(&defaults, "nip66.dnsCacheTtlSeconds", number(300)); +} + +#[test] +fn wot_defaults_are_disabled() { + let defaults = defaults(); + assert_value(&defaults, "wot.enabled", boolean(false)); + assert_value(&defaults, "wot.seedPubkey", Value::String(String::new())); + assert_value(&defaults, "wot.minimumFollowers", number(1)); + assert_value(&defaults, "wot.refreshIntervalHours", number(24)); +} + +#[test] +fn nip56_defaults_are_disabled() { + let defaults = defaults(); + assert_value(&defaults, "nip56.enabled", boolean(false)); + assert_value(&defaults, "nip56.trustedModerators", Value::Sequence(vec![])); +} + +#[test] +fn a_nip43_user_block_overrides_the_defaults() { + let defaults = defaults(); + let user: Value = serde_yaml::from_str( + "nip43:\n enabled: true\n inviteCodeExpirySeconds: 86400\n defaultMaxUses: 5\n", + ) + .expect("user document parses"); + let merged = merge(&defaults, &user); + assert_value(&merged, "nip43.enabled", boolean(true)); + assert_value(&merged, "nip43.inviteCodeExpirySeconds", number(86_400)); + assert_value(&merged, "nip43.defaultMaxUses", number(5)); +} + +#[test] +fn a_nip66_user_block_overrides_only_what_it_names() { + let defaults = defaults(); + let user: Value = serde_yaml::from_str( + "nip66:\n enabled: true\n probeIntervalSeconds: 900\n targets:\n - wss://relay.example.com\n", + ) + .expect("user document parses"); + let merged = merge(&defaults, &user); + assert_value(&merged, "nip66.enabled", boolean(true)); + assert_value(&merged, "nip66.probeIntervalSeconds", number(900)); + assert_value( + &merged, + "nip66.targets[0]", + Value::String("wss://relay.example.com".to_owned()), + ); + assert_value(&merged, "nip66.timeouts.dnsMs", number(10_000)); + assert_value(&merged, "nip66.dnsCacheTtlSeconds", number(300)); +} + +#[test] +fn a_wot_user_block_keeps_unmentioned_fields() { + let defaults = defaults(); + let user: Value = + serde_yaml::from_str("wot:\n enabled: true\n seedPubkey: abc123\n minimumFollowers: 3\n") + .expect("user document parses"); + let merged = merge(&defaults, &user); + assert_value(&merged, "wot.enabled", boolean(true)); + assert_value(&merged, "wot.seedPubkey", Value::String("abc123".to_owned())); + assert_value(&merged, "wot.minimumFollowers", number(3)); + assert_value(&merged, "wot.refreshIntervalHours", number(24)); +} + +#[test] +fn a_nip56_user_block_overrides_the_defaults() { + let defaults = defaults(); + let user: Value = serde_yaml::from_str( + "nip56:\n enabled: true\n trustedModerators:\n - aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n", + ) + .expect("user document parses"); + let merged = merge(&defaults, &user); + assert_value(&merged, "nip56.enabled", boolean(true)); + assert_value( + &merged, + "nip56.trustedModerators[0]", + Value::String("a".repeat(64)), + ); +} diff --git a/crates/nostream-config/tests/defaults_roundtrip.rs b/crates/nostream-config/tests/defaults_roundtrip.rs new file mode 100644 index 000000000..012c5b8f8 --- /dev/null +++ b/crates/nostream-config/tests/defaults_roundtrip.rs @@ -0,0 +1,108 @@ +//! The typed schema must round-trip the shipped defaults: parse → deserialize → serialize is +//! the same document. This is what proves every serde rename, every `Option`, and every +//! untagged enum is right, and it is why `validate(&Settings)` may delegate to the document +//! validator. + +use std::fs; + +use nostream_config::settings::Settings; +use nostream_config::validate_document; +use serde_yaml::Value; + +fn defaults() -> Value { + let raw = fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../resources/default-settings.yaml" + )) + .expect("the shipped defaults are readable"); + serde_yaml::from_str(&raw).expect("the shipped defaults are valid YAML") +} + +#[test] +fn the_shipped_defaults_validate_with_no_issues() { + let defaults = defaults(); + let issues = validate_document(&defaults, &defaults); + assert_eq!(issues, Vec::new(), "the shipped defaults must validate clean"); +} + +#[test] +fn the_typed_schema_round_trips_the_defaults() { + let defaults = defaults(); + let settings: Settings = + serde_yaml::from_value(defaults.clone()).expect("the defaults fit the typed schema"); + let again = serde_yaml::to_value(&settings).expect("the typed settings serialize"); + + let mut differences = Vec::new(); + first_differences(&again, &defaults, "$", &mut differences, 10); + assert!( + differences.is_empty(), + "serializing the typed settings must reproduce the defaults document:\n{}", + differences.join("\n") + ); +} + +#[test] +fn the_typed_settings_revalidate_clean() { + let defaults = defaults(); + let settings: Settings = + serde_yaml::from_value(defaults.clone()).expect("the defaults fit the typed schema"); + let issues = nostream_config::validate(&settings, &defaults).expect("validation runs"); + assert_eq!(issues, Vec::new()); +} + +/// Collects up to `limit` path-level differences between two documents. +fn first_differences( + left: &Value, + right: &Value, + path: &str, + differences: &mut Vec, + limit: usize, +) { + if differences.len() >= limit { + return; + } + match (left, right) { + (Value::Mapping(left_map), Value::Mapping(right_map)) => { + for (key, left_value) in left_map { + let child = format!("{path}.{}", key.as_str().unwrap_or("?")); + match right_map.get(key) { + Some(right_value) => { + first_differences(left_value, right_value, &child, differences, limit) + } + None => differences.push(format!("{child}: only in serialized output")), + } + } + for (key, _) in right_map { + if !left_map.contains_key(key) { + let child = format!("{path}.{}", key.as_str().unwrap_or("?")); + differences.push(format!("{child}: missing from serialized output")); + } + } + } + (Value::Sequence(left_items), Value::Sequence(right_items)) => { + if left_items.len() != right_items.len() { + differences.push(format!( + "{path}: length {} vs {}", + left_items.len(), + right_items.len() + )); + return; + } + for (index, (left_item, right_item)) in + left_items.iter().zip(right_items.iter()).enumerate() + { + first_differences( + left_item, + right_item, + &format!("{path}[{index}]"), + differences, + limit, + ); + } + } + (left, right) if left != right => { + differences.push(format!("{path}: {left:?} vs {right:?}")); + } + _ => {} + } +} diff --git a/crates/nostream-config/tests/fixtures/ts-issues.json b/crates/nostream-config/tests/fixtures/ts-issues.json new file mode 100644 index 000000000..c434abe6f --- /dev/null +++ b/crates/nostream-config/tests/fixtures/ts-issues.json @@ -0,0 +1,258 @@ +{ + "subject": { + "repo": "cameri/nostream", + "commit": "242a3001d77149e4d70151ec03060c4ef44a1547", + "files": [ + "resources/default-settings.yaml", + "src/@types/settings.ts", + "src/utils/settings-config.ts", + "src/utils/settings.ts", + "test/unit/utils/settings-config.spec.ts" + ], + "node": "v24.22.0", + "generated": "2026-10-17", + "command": "cd && node -r ts-node/register/transpile-only /crates/nostream-config/tests/capture/gen-ts-issues.mjs", + "expected_test_counts": { + "source_it": 34, + "executed": 41 + } + }, + "deferred": { + "commit": "041fe34e66b9bb433f1ff3383aee7030fdd3bb8f", + "files": [ + "resources/default-settings.yaml", + "src/@types/settings.ts", + "src/utils/settings-config.ts", + "src/utils/settings-guided-schema.ts", + "test/unit/utils/settings-config.spec.ts" + ], + "diff": "+212/-0" + }, + "cases": [ + { + "name": "defaults", + "mutations": [], + "ts_issues": [], + "rust_exempt_paths": [] + }, + { + "name": "backend_lmdb", + "mutations": [ + { "op": "set", "path": "eventStore.backend", "value": "lmdb" } + ], + "ts_issues": [ + { + "path": "eventStore.backend", + "message": "backend must be one of: postgres" + } + ], + "rust_exempt_paths": ["eventStore.backend"], + "exempt_reason": "D7: the rewrite's target backend is LMDB, so the Rust validator accepts lmdb and rejects only values outside {postgres, lmdb}. Path-scoped, not case-scoped." + }, + { + "name": "pow_floor_gt_ceiling", + "mutations": [ + { "op": "set", "path": "limits.event.pow.enabled", "value": true }, + { "op": "set", "path": "limits.event.pow.floorBits", "value": 16 }, + { "op": "set", "path": "limits.event.pow.ceilingBits", "value": 8 } + ], + "ts_issues": [ + { + "path": "limits.event.pow.floorBits", + "message": "floorBits must be >= 0 and <= ceilingBits" + } + ], + "rust_exempt_paths": [] + }, + { + "name": "pow_period_zero", + "mutations": [ + { "op": "set", "path": "limits.event.pow.enabled", "value": true }, + { "op": "set", "path": "limits.event.pow.periodMs", "value": 0 } + ], + "ts_issues": [ + { + "path": "limits.event.pow.periodMs", + "message": "periodMs must be greater than 0" + } + ], + "rust_exempt_paths": [] + }, + { + "name": "pow_target_zero", + "mutations": [ + { "op": "set", "path": "limits.event.pow.enabled", "value": true }, + { "op": "set", "path": "limits.event.pow.targetEventsPerSecond", "value": 0 } + ], + "ts_issues": [ + { + "path": "limits.event.pow.targetEventsPerSecond", + "message": "targetEventsPerSecond must be greater than 0" + } + ], + "rust_exempt_paths": [] + }, + { + "name": "conn_rate_limit_period_zero", + "mutations": [ + { "op": "set", "path": "limits.connection.rateLimits[0].period", "value": 0 } + ], + "ts_issues": [ + { + "path": "limits.connection.rateLimits[0].period", + "message": "period must be greater than 0" + } + ], + "rust_exempt_paths": [] + }, + { + "name": "telegram_missing_creds", + "mutations": [ + { + "op": "set", + "path": "admin.notifications.targets", + "value": [{ "id": "t1", "type": "telegram", "enabled": true }] + } + ], + "ts_issues": [ + { + "path": "admin.notifications.targets[0].botToken", + "message": "botToken is required for telegram targets" + }, + { + "path": "admin.notifications.targets[0].chatId", + "message": "chatId is required for telegram targets" + } + ], + "rust_exempt_paths": [] + }, + { + "name": "duplicate_target_id", + "mutations": [ + { + "op": "set", + "path": "admin.notifications.targets", + "value": [ + { + "id": "dup", + "type": "http", + "enabled": true, + "url": "https://a.example" + }, + { + "id": "dup", + "type": "http", + "enabled": true, + "url": "https://b.example" + } + ] + } + ], + "ts_issues": [ + { + "path": "admin.notifications.targets[1].id", + "message": "target id must be unique" + } + ], + "rust_exempt_paths": [] + }, + { + "name": "target_bad_url", + "mutations": [ + { + "op": "set", + "path": "admin.notifications.targets", + "value": [ + { + "id": "w1", + "type": "http", + "url": "ftp://bad.example" + } + ] + } + ], + "ts_issues": [ + { + "path": "admin.notifications.targets[0].url", + "message": "url must use http or https" + } + ], + "rust_exempt_paths": [] + }, + { + "name": "unknown_top_level", + "mutations": [{ "op": "set", "path": "bogusSection", "value": {} }], + "ts_issues": [ + { "path": "bogusSection", "message": "Unknown setting key" } + ], + "rust_exempt_paths": [] + }, + { + "name": "unknown_nested_key", + "mutations": [{ "op": "set", "path": "info.unknownKey", "value": 1 }], + "ts_issues": [ + { "path": "info.unknownKey", "message": "Unknown setting key" } + ], + "rust_exempt_paths": [] + }, + { + "name": "wrong_type_workers_count", + "mutations": [ + { "op": "set", "path": "workers.count", "value": "four" } + ], + "ts_issues": [ + { "path": "workers.count", "message": "Expected number, got string" } + ], + "rust_exempt_paths": [] + }, + { + "name": "missing_name", + "mutations": [{ "op": "delete", "path": "info.name" }], + "ts_issues": [{ "path": "info.name", "message": "name is required" }], + "rust_exempt_paths": [] + }, + { + "name": "missing_relay_url", + "mutations": [{ "op": "delete", "path": "info.relay_url" }], + "ts_issues": [ + { "path": "info.relay_url", "message": "relay_url is required" } + ], + "rust_exempt_paths": [] + }, + { + "name": "bad_strategy", + "mutations": [ + { "op": "set", "path": "limits.rateLimiter.strategy", "value": "random" } + ], + "ts_issues": [ + { + "path": "limits.rateLimiter.strategy", + "message": "strategy must be ewma or sliding_window" + } + ], + "rust_exempt_paths": [] + }, + { + "name": "nip77_present", + "mutations": [ + { "op": "set", "path": "nip77", "value": { "enabled": true } } + ], + "ts_issues": [ + { "path": "nip77", "message": "Unknown setting key" } + ], + "rust_exempt_paths": [] + }, + { + "name": "relay_url_ftp", + "mutations": [ + { + "op": "set", + "path": "info.relay_url", + "value": "ftp://relay.example.com" + } + ], + "ts_issues": [], + "rust_exempt_paths": [] + } + ] +} diff --git a/crates/nostream-config/tests/guided.rs b/crates/nostream-config/tests/guided.rs new file mode 100644 index 000000000..4254b737a --- /dev/null +++ b/crates/nostream-config/tests/guided.rs @@ -0,0 +1,86 @@ +//! The guided settings catalogue, ported from the `settings-guided-schema` block of +//! `test/unit/utils/settings-config.spec.ts`. + +use nostream_config::guided::{ + GuidedSettingFieldType, GuidedSettingValidator, get_guided_setting_category, + get_guided_setting_field, guided_setting_categories, require_non_empty_setting_value, + require_safe_non_negative_integer_setting_value, +}; + +#[test] +fn exports_guided_categories_for_admin_and_cli_use() { + assert!(!guided_setting_categories().is_empty()); + let payments = get_guided_setting_category("payments").expect("payments category"); + assert!( + payments + .settings + .iter() + .any(|entry| entry.path == "payments.enabled") + ); +} + +#[test] +fn looks_up_a_field_by_category_and_path() { + let field = get_guided_setting_field("limits", "limits.rateLimiter.strategy") + .expect("strategy field"); + assert_eq!(field.field_type, GuidedSettingFieldType::Select); + assert_eq!(field.options, vec!["ewma", "sliding_window"]); +} + +#[test] +fn categories_and_paths_are_unique() { + for category in guided_setting_categories() { + let mut paths = category + .settings + .iter() + .map(|field| field.path.as_str()) + .collect::>(); + paths.sort_unstable(); + let unique = { + let mut paths = paths.clone(); + paths.dedup(); + paths + }; + assert_eq!(paths, unique, "duplicate field path in {}", category.value); + assert!(!category.settings.is_empty(), "{} is empty", category.value); + } +} + +#[test] +fn validates_guided_numeric_fields() { + assert_eq!( + require_safe_non_negative_integer_setting_value("bad"), + Some("Value must be a non-negative integer") + ); + assert_eq!(require_safe_non_negative_integer_setting_value("2048"), None); + assert_eq!( + require_safe_non_negative_integer_setting_value("9007199254740992"), + Some("Value must be a safe integer") + ); + assert_eq!(require_non_empty_setting_value(" "), Some("Value is required")); + assert_eq!(require_non_empty_setting_value("relay.example.com"), None); +} + +#[test] +fn validators_are_reachable_from_the_field() { + let field = get_guided_setting_field("network", "network.maxPayloadSize") + .expect("maxPayloadSize field"); + let validator = field.validate.expect("the field validates"); + assert_eq!( + validator.validate("-1"), + Some("Value must be a non-negative integer") + ); + assert_eq!(validator.validate("524288"), None); + assert_eq!(GuidedSettingValidator::NonEmpty.validate(" "), Some("Value is required")); +} + +#[test] +fn deferred_nip77_surface_is_not_modelled() { + assert!(get_guided_setting_category("nip77").is_none()); + assert!(guided_setting_categories().iter().all(|category| { + category + .settings + .iter() + .all(|field| !field.path.starts_with("nip77")) + })); +} diff --git a/crates/nostream-config/tests/load.rs b/crates/nostream-config/tests/load.rs new file mode 100644 index 000000000..e360fffa2 --- /dev/null +++ b/crates/nostream-config/tests/load.rs @@ -0,0 +1,193 @@ +//! Loader behaviour: file precedence, the deep merge, and the fallback-to-defaults rule, ported +//! from `test/unit/utils/settings.spec.ts` and the precedence block of +//! `test/unit/utils/settings-config.spec.ts`. + +use std::fs; +use std::path::{Path, PathBuf}; + +use nostream_config::load::{load, load_user_settings, merge}; +use nostream_config::path::get_by_path; +use nostream_config::SettingsPaths; +use serde_yaml::Value; +use tempfile::TempDir; + +fn repo_root() -> &'static Path { + Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../..")) +} + +fn paths(config_dir: &Path) -> SettingsPaths { + SettingsPaths::from_config_dir(repo_root(), config_dir) +} + +fn write(config_dir: &Path, name: &str, contents: &str) -> PathBuf { + let path = config_dir.join(name); + fs::write(&path, contents).expect("the test writes its fixture"); + path +} + +#[test] +fn the_shipped_defaults_load_clean() { + let config_dir = TempDir::new().expect("tempdir"); + let outcome = load(&paths(config_dir.path())).expect("the defaults load"); + + assert_eq!(outcome.issues, Vec::new()); + assert!(!outcome.used_defaults); + assert_eq!(outcome.settings.info.name, "nostream.your-domain.com"); + assert_eq!( + outcome.settings.info.relay_url, + "wss://nostream.your-domain.com" + ); +} + +#[test] +fn the_loader_creates_the_config_directory() { + let config_dir = TempDir::new().expect("tempdir"); + let target = config_dir.path().join("nested").join("config"); + let user = load_user_settings(&target).expect("the directory is created"); + assert!(user.is_none()); + assert!(target.is_dir()); +} + +#[test] +fn a_yaml_override_is_deep_merged() { + let config_dir = TempDir::new().expect("tempdir"); + write( + config_dir.path(), + "settings.yaml", + "info:\n name: my-relay\nnip43:\n enabled: true\n", + ); + let outcome = load(&paths(config_dir.path())).expect("the settings load"); + + assert_eq!(outcome.issues, Vec::new()); + assert!(!outcome.used_defaults); + assert_eq!(outcome.settings.info.name, "my-relay"); + assert_eq!( + outcome.settings.info.relay_url, + "wss://nostream.your-domain.com", + "unmentioned fields keep their defaults" + ); + assert_eq!( + outcome.settings.nip43.and_then(|nip43| Some(nip43.enabled)), + Some(true) + ); +} + +#[test] +fn an_invalid_override_falls_back_to_the_defaults_and_reports_the_issues() { + let config_dir = TempDir::new().expect("tempdir"); + write( + config_dir.path(), + "settings.yaml", + "limits:\n rateLimiter:\n strategy: random\n", + ); + let outcome = load(&paths(config_dir.path())).expect("the settings load"); + + assert!(outcome.used_defaults); + assert_eq!( + outcome + .issues + .iter() + .map(|issue| issue.to_string()) + .collect::>(), + vec!["limits.rateLimiter.strategy: strategy must be ewma or sliding_window"] + ); + assert_eq!( + outcome.settings.info.name, "nostream.your-domain.com", + "the fallback is the typed defaults, not the user's document" + ); +} + +#[test] +fn a_broken_yaml_file_falls_back_with_an_issue() { + let config_dir = TempDir::new().expect("tempdir"); + write(config_dir.path(), "settings.yaml", "info: [unclosed\n"); + let outcome = load(&paths(config_dir.path())).expect("the defaults still load"); + + assert!(outcome.used_defaults); + assert_eq!(outcome.issues.len(), 1); + assert_eq!(outcome.issues[0].path, "$"); + assert!(outcome.issues[0].message.contains("failed to load user settings")); +} + +#[test] +fn the_deprecated_json_file_is_read_when_yaml_is_absent() { + let config_dir = TempDir::new().expect("tempdir"); + write( + config_dir.path(), + "settings.json", + "{\"info\": {\"name\": \"legacy\"}}", + ); + let user = load_user_settings(config_dir.path()) + .expect("the legacy file loads") + .expect("an override exists"); + assert_eq!( + get_by_path(&user, "info.name").expect("path").cloned(), + Some(Value::String("legacy".to_owned())) + ); +} + +#[test] +fn yaml_takes_precedence_over_the_deprecated_json_file() { + let config_dir = TempDir::new().expect("tempdir"); + write( + config_dir.path(), + "settings.json", + "{\"info\": {\"name\": \"legacy\"}}", + ); + write(config_dir.path(), "settings.yaml", "info:\n name: current\n"); + + let user = load_user_settings(config_dir.path()) + .expect("the file loads") + .expect("an override exists"); + assert_eq!( + get_by_path(&user, "info.name").expect("path").cloned(), + Some(Value::String("current".to_owned())) + ); +} + +#[test] +fn an_empty_yaml_file_is_an_empty_override_set() { + let config_dir = TempDir::new().expect("tempdir"); + write(config_dir.path(), "settings.yaml", ""); + let user = load_user_settings(config_dir.path()).expect("the file loads"); + assert_eq!(user, Some(Value::Mapping(serde_yaml::Mapping::new()))); +} + +#[test] +fn merge_replaces_arrays_and_keeps_untouched_keys() { + let defaults: Value = + serde_yaml::from_str("a:\n b: 1\n list:\n - 1\n - 2\n").expect("doc"); + let user: Value = serde_yaml::from_str("a:\n list:\n - 9\n").expect("doc"); + let merged = merge(&defaults, &user); + assert_eq!( + get_by_path(&merged, "a.list").expect("path").cloned(), + Some(serde_yaml::from_str::("[9]").expect("seq")) + ); + assert_eq!( + get_by_path(&merged, "a.b").expect("path").cloned(), + Some(Value::Number(1.into())) + ); +} + +#[test] +fn a_config_dir_override_wins_over_the_environment() { + let config_dir = TempDir::new().expect("tempdir"); + let resolved = SettingsPaths::from_config_dir(Path::new("/srv/relay"), config_dir.path()); + assert_eq!(resolved.config_base_dir, config_dir.path()); + assert_eq!(resolved.settings_file, config_dir.path().join("settings.yaml")); + assert_eq!( + resolved.legacy_settings_file, + config_dir.path().join("settings.json") + ); + assert_eq!(resolved.backups_dir, config_dir.path().join("backups")); + assert_eq!( + resolved.audit_log, + config_dir.path().join("settings-audit.jsonl") + ); + assert_eq!( + resolved.default_settings_file, + Path::new("/srv/relay") + .join("resources") + .join("default-settings.yaml") + ); +} diff --git a/crates/nostream-config/tests/notifications_merge.rs b/crates/nostream-config/tests/notifications_merge.rs new file mode 100644 index 000000000..d3ed66a9c --- /dev/null +++ b/crates/nostream-config/tests/notifications_merge.rs @@ -0,0 +1,125 @@ +//! Admin-notification merge and patch semantics, ported from +//! `test/unit/utils/admin-notifications-settings.spec.ts`. + +use nostream_config::notifications::{ + AdminNotificationsPatch, AdminNotificationsSettings, get_merged_admin_notifications, + merge_admin_notifications_patch, +}; + +fn settings(raw: &str) -> AdminNotificationsSettings { + serde_yaml::from_str(raw).expect("settings parse") +} + +fn patch(raw: &str) -> AdminNotificationsPatch { + serde_yaml::from_str(raw).expect("patch parses") +} + +fn current_with_discord() -> AdminNotificationsSettings { + settings( + "enabled: true\ntargets:\n - id: discord-1\n type: discord\n enabled: true\n url: https://discord.com/api/webhooks/secret\nevents:\n settings.changed: true\nretry:\n maxAttempts: 5\n baseDelayMs: 1000\n", + ) +} + +#[test] +fn merges_patch_values_and_preserves_redacted_webhook_secrets() { + let current = current_with_discord(); + let next = merge_admin_notifications_patch( + ¤t, + &patch("targets:\n - id: discord-1\n type: discord\n enabled: false\n url: '***'\n"), + ); + + assert_eq!(next.targets[0].enabled, Some(false)); + assert_eq!( + next.targets[0].url.as_deref(), + Some("https://discord.com/api/webhooks/secret") + ); +} + +#[test] +fn does_not_persist_redacted_placeholders_for_new_targets() { + let current = settings( + "enabled: true\ntargets: []\nevents: {}\nretry:\n maxAttempts: 5\n baseDelayMs: 1000\n", + ); + let next = merge_admin_notifications_patch( + ¤t, + &patch("targets:\n - id: telegram-new\n type: telegram\n enabled: true\n botToken: '***'\n chatId: '123'\n"), + ); + + assert_eq!(next.targets[0].bot_token, None); + assert_eq!(next.targets[0].chat_id.as_deref(), Some("123")); +} + +#[test] +fn does_not_persist_a_redacted_url_for_new_http_targets() { + let current = settings( + "enabled: true\ntargets: []\nevents: {}\nretry:\n maxAttempts: 5\n baseDelayMs: 1000\n", + ); + let next = merge_admin_notifications_patch( + ¤t, + &patch("targets:\n - id: http-new\n type: http\n enabled: true\n url: '***'\n"), + ); + + assert_eq!(next.targets[0].url, None); +} + +#[test] +fn a_patch_merges_events_and_retry_and_replaces_scalars() { + let current = current_with_discord(); + let next = merge_admin_notifications_patch( + ¤t, + &patch("enabled: false\nevents:\n relay.restarted: true\nretry:\n maxAttempts: 9\ndeliveryLogRetentionDays: 7\n"), + ); + + assert!(!next.enabled); + assert_eq!(next.events.settings_changed, Some(true)); + assert_eq!(next.events.relay_restarted, Some(true)); + assert_eq!(next.retry.max_attempts, Some(9)); + assert_eq!(next.retry.base_delay_ms, Some(1000)); + assert_eq!(next.delivery_log_retention_days, Some(7)); + assert_eq!(next.targets.len(), 1, "a patch without targets keeps the current ones"); +} + +#[test] +fn a_patch_replaces_targets_wholesale() { + let current = current_with_discord(); + let next = merge_admin_notifications_patch( + ¤t, + &patch("targets:\n - id: replacement\n type: http\n enabled: true\n url: https://a.example/hook\n"), + ); + + assert_eq!(next.targets.len(), 1); + assert_eq!(next.targets[0].id, "replacement"); + assert_eq!( + next.targets[0].url.as_deref(), + Some("https://a.example/hook") + ); +} + +#[test] +fn the_effective_block_falls_back_to_the_capture_defaults() { + let merged = get_merged_admin_notifications(None, None); + assert!(!merged.enabled); + assert!(merged.targets.is_empty()); + assert_eq!(merged.retry.max_attempts, Some(5)); + assert_eq!(merged.retry.base_delay_ms, Some(1000)); + assert_eq!(merged.delivery_log_retention_days, Some(30)); + assert_eq!(merged.events, Default::default()); +} + +#[test] +fn configured_values_win_and_defaults_fill_the_gaps() { + let defaults = settings( + "enabled: false\ntargets: []\nevents:\n settings.changed: true\n relay.restarted: false\nretry:\n maxAttempts: 3\n baseDelayMs: 250\n", + ); + let configured = settings( + "enabled: true\ntargets:\n - id: t\n type: telegram\n botToken: token\n chatId: chat\nevents:\n relay.restarted: true\nretry:\n maxAttempts: 8\n", + ); + let merged = get_merged_admin_notifications(Some(&configured), Some(&defaults)); + + assert!(merged.enabled); + assert_eq!(merged.retry.max_attempts, Some(8)); + assert_eq!(merged.retry.base_delay_ms, Some(250)); + assert_eq!(merged.events.settings_changed, Some(true)); + assert_eq!(merged.events.relay_restarted, Some(true)); + assert_eq!(merged.delivery_log_retention_days, Some(30)); +} diff --git a/crates/nostream-config/tests/path_editor.rs b/crates/nostream-config/tests/path_editor.rs new file mode 100644 index 000000000..81e090610 --- /dev/null +++ b/crates/nostream-config/tests/path_editor.rs @@ -0,0 +1,194 @@ +//! The dot-path editor and value parser, ported from `test/unit/utils/settings-config.spec.ts`. + +use std::fs; + +use nostream_config::path::{ + PathError, ParsedValue, get_by_path, parse_path, parse_typed_json, parse_value, set_by_path, + validate_path_against_defaults, +}; +use serde_yaml::Value; + +fn defaults() -> Value { + let raw = fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../resources/default-settings.yaml" + )) + .expect("the shipped defaults are readable"); + serde_yaml::from_str(&raw).expect("the shipped defaults are valid YAML") +} + +#[test] +fn parses_primitive_values() { + assert_eq!(parse_value("true"), ParsedValue::Bool(true)); + assert_eq!(parse_value("false"), ParsedValue::Bool(false)); + assert_eq!(parse_value("42"), ParsedValue::Number(42)); + assert_eq!(parse_value("42n"), ParsedValue::BigInt("42".to_owned())); + assert_eq!(parse_value("null"), ParsedValue::Null); + assert_eq!(parse_value("hello"), ParsedValue::String("hello".to_owned())); +} + +#[test] +fn an_unsafe_integer_falls_back_to_a_string() { + assert_eq!( + parse_value("99999999999999999999"), + ParsedValue::String("99999999999999999999".to_owned()) + ); +} + +#[test] +fn parses_typed_json_values() { + let object = parse_typed_json("{\"enabled\":true}").expect("json object"); + assert_eq!( + get_by_path(&object, "enabled").expect("path"), + Some(&Value::Bool(true)) + ); + let array = parse_typed_json("[1,2,3]").expect("json array"); + assert!(matches!(array, Value::Sequence(entries) if entries.len() == 3)); + assert!( + parse_typed_json("{") + .expect_err("invalid json") + .starts_with("Invalid JSON value") + ); +} + +#[test] +fn sets_and_gets_dot_path_values() { + let document: Value = serde_yaml::from_str("payments:\n enabled: false\n").expect("doc"); + let updated = set_by_path(&document, "payments.enabled", Value::Bool(true)).expect("set"); + assert_eq!( + get_by_path(&updated, "payments.enabled").expect("get"), + Some(&Value::Bool(true)) + ); + assert_eq!(get_by_path(&updated, "payments.processor").expect("get"), None); +} + +#[test] +fn supports_indexed_path_syntax() { + let document: Value = serde_yaml::from_str( + "limits:\n event:\n content:\n - maxLength: 100\n", + ) + .expect("doc"); + let updated = set_by_path( + &document, + "limits.event.content[0].maxLength", + Value::Number(500.into()), + ) + .expect("set"); + assert_eq!( + get_by_path(&updated, "limits.event.content[0].maxLength").expect("get"), + Some(&Value::Number(500.into())) + ); +} + +#[test] +fn rejects_malformed_path_syntax() { + let document: Value = serde_yaml::from_str("{}").expect("doc"); + assert_eq!( + set_by_path(&document, "payments[]", Value::Bool(true)), + Err(PathError::InvalidSegment("payments[]".to_owned())) + ); +} + +#[test] +fn rejects_reserved_prototype_keys() { + let document: Value = serde_yaml::from_str("payments:\n enabled: false\n").expect("doc"); + for path in ["__proto__.enabled", "constructor.enabled", "prototype.enabled"] { + let key = path.split('.').next().expect("key"); + assert_eq!( + set_by_path(&document, path, Value::Bool(true)), + Err(PathError::InvalidSegment(key.to_owned())) + ); + assert_eq!( + get_by_path(&document, path), + Err(PathError::InvalidSegment(key.to_owned())) + ); + } +} + +#[test] +fn requires_a_path() { + let document: Value = serde_yaml::from_str("{}").expect("doc"); + assert_eq!(parse_path(" "), Err(PathError::Required)); + assert_eq!( + set_by_path(&document, "", Value::Null), + Err(PathError::Required) + ); +} + +#[test] +fn rejects_an_empty_segment() { + assert_eq!( + parse_path("info..name"), + Err(PathError::InvalidSegmentIn("info..name".to_owned())) + ); +} + +#[test] +fn validates_known_paths_against_the_defaults() { + let defaults = defaults(); + assert_eq!( + validate_path_against_defaults(&defaults, "payments.enabled").expect("path"), + Vec::new() + ); + assert_eq!( + validate_path_against_defaults(&defaults, "limits.event.content[0].maxLength") + .expect("path"), + Vec::new() + ); + let issues = validate_path_against_defaults(&defaults, "payments.fakeField").expect("path"); + assert_eq!(issues[0].path, "payments.fakeField"); + assert!(issues[0].message.contains("does not exist")); +} + +#[test] +fn extends_arrays_when_an_index_is_beyond_the_end() { + let defaults = defaults(); + let updated = set_by_path( + &defaults, + "mirroring.static[1].address", + Value::String("wss://mirror.example".to_owned()), + ) + .expect("set"); + let Value::Sequence(entries) = get_by_path(&updated, "mirroring.static") + .expect("path") + .expect("the list exists") + else { + panic!("mirroring.static is a sequence"); + }; + assert_eq!(entries.len(), 2); + assert_eq!(entries[0], Value::Null); + assert_eq!( + get_by_path(&updated, "mirroring.static[1].address").expect("path"), + Some(&Value::String("wss://mirror.example".to_owned())) + ); +} + +#[test] +fn replacing_a_scalar_with_an_object_follows_the_path_shape() { + let document: Value = serde_yaml::from_str("workers:\n count: 0\n").expect("doc"); + let updated = set_by_path(&document, "workers.count.value", Value::Null).expect("set"); + assert_eq!( + get_by_path(&updated, "workers.count.value").expect("path"), + Some(&Value::Null) + ); +} + +#[test] +fn a_key_followed_by_an_index_becomes_an_array() { + // The capture replaces a non-array value with `[]` when the next token is an index, + // so this is an assignment, not an error. + let document: Value = serde_yaml::from_str("network: {}\n").expect("doc"); + let updated = set_by_path(&document, "network[0]", Value::Null).expect("set"); + assert_eq!( + get_by_path(&updated, "network").expect("path").cloned(), + Some(serde_yaml::from_str::("[null]").expect("seq")) + ); +} + +#[test] +fn reading_a_missing_path_is_none() { + let document: Value = serde_yaml::from_str("info:\n name: relay\n").expect("doc"); + assert_eq!(get_by_path(&document, "info.relay_url").expect("path"), None); + assert_eq!(get_by_path(&document, "network.maxPayloadSize").expect("path"), None); + assert_eq!(get_by_path(&document, "info[0]").expect("path"), None); +} diff --git a/crates/nostream-config/tests/redaction.rs b/crates/nostream-config/tests/redaction.rs new file mode 100644 index 000000000..72f8acfba --- /dev/null +++ b/crates/nostream-config/tests/redaction.rs @@ -0,0 +1,99 @@ +//! Redaction, ported from `test/unit/utils/settings-redaction.spec.ts`. + +use nostream_config::path::get_by_path; +use nostream_config::redaction::{ + is_sensitive_settings_path, is_write_protected_settings_path, redact_settings_secrets, + redact_settings_value, +}; +use serde_yaml::Value; + +fn document(raw: &str) -> Value { + serde_yaml::from_str(raw).expect("document parses") +} + +#[test] +fn redacts_password_hashes_and_nested_secrets() { + let input = document( + "admin:\n enabled: true\n passwordHash: hashed-password-value\nmirroring:\n static:\n - address: wss://mirror.example\n secret: mirror-secret-value\npayments:\n enabled: false\n", + ); + let redacted = redact_settings_secrets(&input); + + let value = |path: &str| get_by_path(&redacted, path).expect("path").cloned(); + + assert_eq!( + value("admin.passwordHash"), + Some(Value::String("***".to_owned())) + ); + assert_eq!( + value("mirroring.static[0].secret"), + Some(Value::String("***".to_owned())) + ); + assert_eq!( + value("mirroring.static[0].address"), + Some(Value::String("wss://mirror.example".to_owned())) + ); + assert_eq!(value("payments.enabled"), Some(Value::Bool(false))); +} + +#[test] +fn leaves_empty_secret_values_unchanged() { + let input = document("mirroring:\n static:\n - address: wss://mirror.example\n secret: ''\n"); + let redacted = redact_settings_secrets(&input); + assert_eq!( + get_by_path(&redacted, "mirroring.static[0].secret") + .expect("path") + .cloned(), + Some(Value::String(String::new())) + ); +} + +#[test] +fn identifies_sensitive_and_write_protected_paths() { + assert!(is_sensitive_settings_path("admin.passwordHash")); + assert!(is_sensitive_settings_path("mirroring.static[0].secret")); + assert!(!is_sensitive_settings_path("payments.enabled")); + assert!(is_write_protected_settings_path("admin.passwordHash")); + assert!(!is_write_protected_settings_path("mirroring.static[0].secret")); +} + +#[test] +fn redacts_single_values_by_path() { + assert_eq!( + redact_settings_value( + "mirroring.static[0].secret", + &Value::String("top-secret".to_owned()) + ), + Value::String("***".to_owned()) + ); + assert_eq!( + redact_settings_value("payments.enabled", &Value::Bool(true)), + Value::Bool(true) + ); + assert_eq!( + redact_settings_value( + "admin.notifications.targets[0].url", + &Value::String("https://discord.com/api/webhooks/secret".to_owned()) + ), + Value::String("***".to_owned()) + ); +} + +#[test] +fn redacts_notification_webhook_urls_in_nested_settings() { + let input = document( + "admin:\n notifications:\n targets:\n - id: slack-ops\n type: slack\n enabled: true\n url: https://hooks.slack.com/services/T00/B00/xxxxx\n", + ); + let redacted = redact_settings_secrets(&input); + assert_eq!( + get_by_path(&redacted, "admin.notifications.targets[0].url") + .expect("path") + .cloned(), + Some(Value::String("***".to_owned())) + ); +} + +#[test] +fn a_non_secret_url_somewhere_else_is_left_alone() { + assert!(!is_sensitive_settings_path("info.relay_url")); + assert!(!is_sensitive_settings_path("mirroring.static[0].address")); +} diff --git a/crates/nostream-config/tests/ts_parity.rs b/crates/nostream-config/tests/ts_parity.rs new file mode 100644 index 000000000..2eeb2c5ab --- /dev/null +++ b/crates/nostream-config/tests/ts_parity.rs @@ -0,0 +1,288 @@ +//! Parity against the capture's own validator: for every measured case, the Rust validator +//! must produce the same issues, in the same order, with the same messages. +//! +//! The fixture is measured data (`tests/fixtures/ts-issues.json`, generated by +//! `tests/capture/gen-ts-issues.mjs` against `242a3001`). Exactly one path is exempt — +//! `eventStore.backend`, because the rewrite adds the LMDB backend (spec D7) — and the +//! exemption is recorded per case and asserted here. + +use std::collections::BTreeSet; +use std::fs; + +use nostream_config::path::{PathToken, parse_path, set_by_path}; +use nostream_config::validate_document; +use serde::Deserialize; +use serde_yaml::Value; + +#[derive(Debug, Deserialize)] +struct Fixture { + subject: Subject, + deferred: Deferred, + cases: Vec, +} + +#[derive(Debug, Deserialize)] +struct Subject { + repo: String, + commit: String, + files: Vec, + generated: String, + expected_test_counts: TestCounts, +} + +#[derive(Debug, Deserialize)] +struct TestCounts { + source_it: u32, + executed: u32, +} + +#[derive(Debug, Deserialize)] +struct Deferred { + commit: String, + files: Vec, + diff: String, +} + +#[derive(Debug, Deserialize)] +struct Case { + name: String, + #[serde(default)] + mutations: Vec, + ts_issues: Vec, + #[serde(default)] + rust_exempt_paths: Vec, + #[serde(default)] + exempt_reason: Option, +} + +#[derive(Debug, Deserialize)] +struct Mutation { + op: String, + path: String, + #[serde(default)] + value: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct IssueDto { + path: String, + message: String, +} + +const CAPTURE_COMMIT: &str = "242a3001d77149e4d70151ec03060c4ef44a1547"; +const DEFERRED_COMMIT: &str = "041fe34e66b9bb433f1ff3383aee7030fdd3bb8f"; + +fn fixture() -> Fixture { + let raw = fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/ts-issues.json" + )) + .expect("fixture is readable"); + serde_json::from_str(&raw).expect("fixture is valid JSON") +} + +fn defaults() -> Value { + let raw = fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../resources/default-settings.yaml" + )) + .expect("the shipped defaults are readable"); + serde_yaml::from_str(&raw).expect("the shipped defaults are valid YAML") +} + +fn delete_by_path(document: &Value, path: &str) -> Value { + let tokens = parse_path(path).expect("mutation path parses"); + let mut clone = document.clone(); + let Some((last, _)) = tokens.split_last() else { + panic!("a mutation path is never empty"); + }; + let mut current = &mut clone; + for token in &tokens[..tokens.len() - 1] { + current = match token { + PathToken::Key(key) => match current { + Value::Mapping(mapping) => mapping.get_mut(key.as_str()).expect("parent key exists"), + _ => panic!("parent of {path} is not a mapping"), + }, + PathToken::Index(index) => match current { + Value::Sequence(sequence) => &mut sequence[*index], + _ => panic!("parent of {path} is not a sequence"), + }, + }; + } + match last { + PathToken::Key(key) => { + if let Value::Mapping(mapping) = current { + mapping.remove(key.as_str()); + } + } + PathToken::Index(index) => { + if let Value::Sequence(sequence) = current { + sequence.remove(*index); + } + } + } + clone +} + +fn apply_case(case: &Case, defaults: &Value) -> Vec { + let mut document = defaults.clone(); + for mutation in &case.mutations { + document = match mutation.op.as_str() { + "set" => set_by_path( + &document, + &mutation.path, + mutation.value.clone().expect("a set mutation carries a value"), + ) + .expect("set mutation applies"), + "delete" => delete_by_path(&document, &mutation.path), + other => panic!("unknown mutation op {other}"), + }; + } + + validate_document(&document, defaults) + .into_iter() + .map(|issue| IssueDto { + path: issue.path, + message: issue.message, + }) + .collect() +} + +fn strip_exemptions(issues: &[IssueDto], exempt: &[String]) -> Vec { + issues + .iter() + .filter(|issue| !exempt.contains(&issue.path)) + .cloned() + .collect() +} + +#[test] +fn every_case_matches_the_capture() { + let fixture = fixture(); + let defaults = defaults(); + let mut exemptions = BTreeSet::new(); + for case in &fixture.cases { + exemptions.extend(case.rust_exempt_paths.iter().cloned()); + + let rust = apply_case(case, &defaults); + let rust = strip_exemptions(&rust, &case.rust_exempt_paths); + let ts = strip_exemptions(&case.ts_issues, &case.rust_exempt_paths); + + assert_eq!( + rust, ts, + "case `{}` diverges from the capture (Rust left, capture right)", + case.name + ); + } + + let exemptions = exemptions.into_iter().collect::>().join(","); + println!("cases: {} exemptions: [{exemptions}]", fixture.cases.len()); + assert!(fixture.cases.len() >= 15); +} + +#[test] +fn fixture_metadata_names_the_capture() { + let fixture = fixture(); + assert_eq!(fixture.subject.repo, "cameri/nostream"); + assert_eq!(fixture.subject.commit, CAPTURE_COMMIT); + assert_eq!(fixture.subject.expected_test_counts.source_it, 34); + assert_eq!(fixture.subject.expected_test_counts.executed, 41); + assert_eq!(fixture.deferred.commit, DEFERRED_COMMIT); + assert_eq!(fixture.deferred.diff, "+212/-0"); + assert_eq!(fixture.deferred.files.len(), 5); + assert!(!fixture.subject.generated.is_empty()); + assert!(fixture.subject.files.iter().any(|file| file == "src/utils/settings-config.ts")); +} + +#[test] +fn case_names_are_unique() { + let fixture = fixture(); + let names = fixture + .cases + .iter() + .map(|case| case.name.as_str()) + .collect::>(); + assert_eq!(names.len(), fixture.cases.len(), "duplicate case names in the fixture"); +} + +#[test] +fn the_capture_probes_are_present() { + let fixture = fixture(); + let names = fixture + .cases + .iter() + .map(|case| case.name.as_str()) + .collect::>(); + for expected in [ + "backend_lmdb", + "pow_floor_gt_ceiling", + "pow_period_zero", + "pow_target_zero", + "conn_rate_limit_period_zero", + "telegram_missing_creds", + "duplicate_target_id", + "target_bad_url", + "unknown_top_level", + "unknown_nested_key", + "wrong_type_workers_count", + "missing_name", + "missing_relay_url", + "bad_strategy", + ] { + assert!(names.contains(expected), "missing probe case `{expected}`"); + } + assert!(names.contains("defaults")); +} + +#[test] +fn the_deferred_surfaces_are_pinned_as_absent() { + let fixture = fixture(); + let case = |name: &str| { + fixture + .cases + .iter() + .find(|case| case.name == name) + .unwrap_or_else(|| panic!("case `{name}` is present")) + }; + + let nip77 = case("nip77_present"); + assert_eq!( + nip77.ts_issues, + vec![IssueDto { + path: "nip77".to_owned(), + message: "Unknown setting key".to_owned() + }] + ); + assert_eq!(apply_case(nip77, &defaults()), nip77.ts_issues); + + let ftp = case("relay_url_ftp"); + assert!(ftp.ts_issues.is_empty()); + assert!(apply_case(ftp, &defaults()).is_empty()); +} + +#[test] +fn the_only_exempt_path_is_event_store_backend() { + let fixture = fixture(); + let exemptions = fixture + .cases + .iter() + .flat_map(|case| case.rust_exempt_paths.iter().cloned()) + .collect::>(); + assert_eq!( + exemptions, + BTreeSet::from(["eventStore.backend".to_owned()]), + "exactly one path may be exempt (spec D7)" + ); + + for case in &fixture.cases { + if case.rust_exempt_paths.is_empty() { + assert!(case.exempt_reason.is_none(), "`{}` has a reason but no exemption", case.name); + } else { + assert!( + case.exempt_reason.as_deref().is_some_and(|reason| !reason.is_empty()), + "`{}` exempts a path without recording why", + case.name + ); + } + } +} diff --git a/crates/nostream-config/tests/validator.rs b/crates/nostream-config/tests/validator.rs new file mode 100644 index 000000000..78c314d75 --- /dev/null +++ b/crates/nostream-config/tests/validator.rs @@ -0,0 +1,593 @@ +//! The capture's validator suite, ported: required fields, the eight rate-limit paths, the +//! adaptive-pow matrix, the event-store cases, the shape rules and the admin-notification +//! rules. Each test names what `test/unit/utils/settings-config.spec.ts` asserted. + +use std::fs; + +use nostream_config::load::merge; +use nostream_config::path::set_by_path; +use nostream_config::validate_document; +use nostream_config::value::merge_deep_right; +use serde_yaml::Value; + +fn defaults() -> Value { + let raw = fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../resources/default-settings.yaml" + )) + .expect("the shipped defaults are readable"); + serde_yaml::from_str(&raw).expect("the shipped defaults are valid YAML") +} + +/// The capture's test base: the required fields and an empty `network` section. +fn minimal() -> Value { + serde_yaml::from_str("info:\n relay_url: wss://test.relay\n name: test\nnetwork: {}\n") + .expect("the minimal document parses") +} + +fn patched(patch: &str) -> Value { + let patch: Value = serde_yaml::from_str(patch).expect("the patch parses"); + merge(&minimal(), &patch) +} + +fn issues(document: &Value) -> Vec { + validate_document(document, &defaults()) + .into_iter() + .map(|issue| issue.to_string()) + .collect() +} + +fn paths(document: &Value) -> Vec { + validate_document(document, &defaults()) + .into_iter() + .map(|issue| issue.path) + .collect() +} + +#[test] +fn basic_required_fields_are_reported() { + let document: Value = serde_yaml::from_str("{}").expect("empty document"); + assert_eq!( + issues(&document), + vec![ + "info.relay_url: relay_url is required", + "info.name: name is required", + "network: network section is required", + ] + ); +} + +// --- the eight rate-limit paths --------------------------------------------------------- + +#[test] +fn rejects_a_non_positive_period_on_limits_event_rate_limits() { + let document = patched("limits:\n event:\n rateLimits:\n - period: 0\n rate: 1\n"); + assert_eq!( + paths(&document), + vec!["limits.event.rateLimits[0].period".to_owned()] + ); +} + +#[test] +fn rejects_a_non_positive_period_on_limits_message_rate_limits() { + let document = + patched("limits:\n message:\n rateLimits:\n - period: 0\n rate: 1\n"); + assert_eq!( + paths(&document), + vec!["limits.message.rateLimits[0].period".to_owned()] + ); +} + +#[test] +fn rejects_a_non_positive_period_on_limits_connection_rate_limits() { + let document = + patched("limits:\n connection:\n rateLimits:\n - period: -1\n rate: 1\n"); + assert_eq!( + paths(&document), + vec!["limits.connection.rateLimits[0].period".to_owned()] + ); +} + +#[test] +fn rejects_a_non_positive_period_on_limits_invoice_rate_limits() { + let document = + patched("limits:\n invoice:\n rateLimits:\n - period: 0\n rate: 1\n"); + assert_eq!( + paths(&document), + vec!["limits.invoice.rateLimits[0].period".to_owned()] + ); +} + +#[test] +fn rejects_a_non_positive_period_on_limits_admission_check_rate_limits() { + let document = + patched("limits:\n admissionCheck:\n rateLimits:\n - period: 0\n rate: 1\n"); + assert_eq!( + paths(&document), + vec!["limits.admissionCheck.rateLimits[0].period".to_owned()] + ); +} + +#[test] +fn rejects_a_non_positive_period_on_limits_invite_rate_limits() { + let document = patched("limits:\n invite:\n rateLimits:\n - period: 0\n rate: 1\n"); + assert_eq!( + paths(&document), + vec!["limits.invite.rateLimits[0].period".to_owned()] + ); +} + +#[test] +fn rejects_a_non_positive_period_on_limits_admin_rate_limits() { + let document = patched("limits:\n admin:\n rateLimits:\n - period: 0\n rate: 1\n"); + assert_eq!( + paths(&document), + vec!["limits.admin.rateLimits[0].period".to_owned()] + ); +} + +#[test] +fn rejects_a_non_positive_period_on_limits_admin_login_rate_limits() { + let document = + patched("limits:\n admin:\n loginRateLimits:\n - period: -1\n rate: 1\n"); + assert_eq!( + paths(&document), + vec!["limits.admin.loginRateLimits[0].period".to_owned()] + ); +} + +#[test] +fn accepts_a_positive_period() { + let document = + patched("limits:\n event:\n rateLimits:\n - period: 60000\n rate: 10\n"); + assert_eq!(paths(&document), Vec::::new()); +} + +#[test] +fn rejects_a_null_entry_in_a_rate_limit_array_without_throwing() { + let document = patched("limits:\n event:\n rateLimits:\n - null\n"); + let reported = issues(&document); + assert!(reported.contains(&"limits.event.rateLimits[0]: rateLimits entry must be an object".to_owned())); + assert!( + reported.contains(&"limits.event.rateLimits[0]: Array element does not match expected schema shape".to_owned()), + "the shape walk also rejects the entry, as the capture's does" + ); +} + +#[test] +fn rejects_a_scalar_entry_in_a_rate_limit_array() { + let document = patched("limits:\n event:\n rateLimits:\n - 42\n"); + let reported = issues(&document); + assert!(reported.contains(&"limits.event.rateLimits[0]: rateLimits entry must be an object".to_owned())); + assert!( + reported.contains(&"limits.event.rateLimits[0]: Array element does not match expected schema shape".to_owned()) + ); +} + +#[test] +fn a_non_array_rate_limits_value_is_left_to_the_shape_validator() { + let document = patched("limits:\n event:\n rateLimits: {}\n"); + assert_eq!( + issues(&document), + vec!["limits.event.rateLimits: Expected array, got object"] + ); +} + +// --- adaptive pow ----------------------------------------------------------------------- + +fn pow_with(overrides: &[(&str, Value)]) -> Value { + let mut document = patched( + "limits:\n event:\n pow:\n enabled: true\n floorBits: 8\n ceilingBits: 24\n targetEventsPerSecond: 10\n periodMs: 60000\n", + ); + for (path, value) in overrides { + document = set_by_path( + &document, + &format!("limits.event.pow.{path}"), + value.clone(), + ) + .expect("the pow path is writable"); + } + document +} + +fn boolean(boolean: bool) -> Value { + Value::Bool(boolean) +} + +fn number(number: u64) -> Value { + Value::Number(number.into()) +} + +fn threshold(max_distance: i64, difficulty_factor: f64) -> Value { + serde_yaml::from_str(&format!( + "maxDistance: {max_distance}\ndifficultyFactor: {difficulty_factor}\n" + )) + .expect("threshold parses") +} + +#[test] +fn accepts_a_valid_pow_config() { + assert_eq!(paths(&pow_with(&[])), Vec::::new()); +} + +#[test] +fn rejects_floor_bits_greater_than_ceiling_bits() { + let document = pow_with(&[("floorBits", number(20)), ("ceilingBits", number(10))]); + assert!(paths(&document).contains(&"limits.event.pow.floorBits".to_owned())); +} + +#[test] +fn rejects_a_negative_floor_bits() { + let document = pow_with(&[("floorBits", Value::Number((-1).into()))]); + assert!(paths(&document).contains(&"limits.event.pow.floorBits".to_owned())); +} + +#[test] +fn rejects_a_ceiling_bits_above_256() { + let document = pow_with(&[("ceilingBits", number(300))]); + assert_eq!( + paths(&document), + vec!["limits.event.pow.ceilingBits".to_owned()] + ); +} + +#[test] +fn accepts_a_ceiling_bits_of_exactly_256() { + let document = pow_with(&[("ceilingBits", number(256)), ("floorBits", number(0))]); + assert!(!paths(&document).contains(&"limits.event.pow.ceilingBits".to_owned())); +} + +#[test] +fn rejects_a_non_positive_period_ms() { + let document = pow_with(&[("periodMs", number(0))]); + assert_eq!( + paths(&document), + vec!["limits.event.pow.periodMs".to_owned()] + ); +} + +#[test] +fn rejects_a_non_positive_target_events_per_second() { + let document = pow_with(&[("targetEventsPerSecond", number(0))]); + assert_eq!( + paths(&document), + vec!["limits.event.pow.targetEventsPerSecond".to_owned()] + ); +} + +#[test] +fn skips_pow_validation_entirely_when_disabled() { + let document = patched( + "limits:\n event:\n pow:\n enabled: false\n floorBits: -1\n periodMs: -1\n", + ); + assert!( + !paths(&document) + .iter() + .any(|path| path.starts_with("limits.event.pow")) + ); +} + +#[test] +fn accepts_valid_wot_thresholds() { + let document = pow_with(&[( + "wotThresholds", + Value::Sequence(vec![threshold(1, 0.0), threshold(2, 0.5)]), + )]); + assert!( + !paths(&document) + .iter() + .any(|path| path.starts_with("limits.event.pow.wotThresholds")) + ); +} + +#[test] +fn rejects_a_negative_wot_threshold_max_distance() { + let document = pow_with(&[("wotThresholds", Value::Sequence(vec![threshold(-1, 0.5)]))]); + assert_eq!( + paths(&document), + vec!["limits.event.pow.wotThresholds[0].maxDistance".to_owned()] + ); +} + +#[test] +fn rejects_a_difficulty_factor_outside_the_unit_interval() { + let document = pow_with(&[( + "wotThresholds", + Value::Sequence(vec![threshold(1, -0.1), threshold(2, 1.1)]), + )]); + assert_eq!( + paths(&document), + vec![ + "limits.event.pow.wotThresholds[0].difficultyFactor".to_owned(), + "limits.event.pow.wotThresholds[1].difficultyFactor".to_owned(), + ] + ); +} + +#[test] +fn rejects_null_and_non_object_wot_threshold_entries_without_throwing() { + let document = pow_with(&[( + "wotThresholds", + Value::Sequence(vec![Value::Null, number(42)]), + )]); + assert_eq!( + issues(&document), + vec![ + "limits.event.pow.wotThresholds[0]: wotThresholds entry must be an object", + "limits.event.pow.wotThresholds[1]: wotThresholds entry must be an object", + ] + ); +} + +// --- event store ------------------------------------------------------------------------ + +#[test] +fn ships_postgres_as_the_default_backend() { + let defaults = defaults(); + assert_eq!( + nostream_config::path::get_by_path(&defaults, "eventStore.backend") + .expect("path parses") + .expect("the backend is set"), + &Value::String("postgres".to_owned()) + ); +} + +#[test] +fn accepts_settings_without_an_event_store_section() { + assert!( + !paths(&minimal()) + .iter() + .any(|path| path.starts_with("eventStore")) + ); +} + +#[test] +fn accepts_the_postgres_backend() { + let document = patched("eventStore:\n backend: postgres\n"); + assert!( + !paths(&document) + .iter() + .any(|path| path.starts_with("eventStore")) + ); +} + +#[test] +fn rejects_an_unsupported_backend() { + let document = patched("eventStore:\n backend: mongodb\n"); + assert_eq!( + issues(&document), + vec!["eventStore.backend: backend must be one of: postgres, lmdb"] + ); +} + +// --- the shape rules -------------------------------------------------------------------- + +#[test] +fn rejects_an_unknown_top_level_key() { + let document = patched("bogusSection: {}\n"); + assert_eq!(issues(&document), vec!["bogusSection: Unknown setting key"]); +} + +#[test] +fn rejects_an_unknown_nested_key() { + let document = patched("info:\n unknownKey: 1\n"); + assert_eq!(issues(&document), vec!["info.unknownKey: Unknown setting key"]); +} + +#[test] +fn rejects_a_wrong_scalar_type() { + let document = patched("workers:\n count: four\n"); + assert_eq!( + issues(&document), + vec!["workers.count: Expected number, got string"] + ); +} + +#[test] +fn rejects_an_array_where_the_schema_is_an_object() { + let document = patched("network: []\n"); + assert_eq!( + issues(&document), + vec!["network: Expected object, got object"] + ); +} + +#[test] +fn rejects_null_where_the_schema_is_not_null() { + let document = patched("workers:\n count: null\n"); + assert_eq!( + issues(&document), + vec!["workers.count: Expected number, got null"] + ); +} + +#[test] +fn rejects_an_array_element_that_matches_no_schema_entry() { + let document = patched("network:\n trustedProxies:\n - 1\n"); + assert_eq!( + issues(&document), + vec!["network.trustedProxies[0]: Array element does not match expected schema shape"] + ); +} + +#[test] +fn the_shape_walk_skips_admin_password_hash() { + let defaults = defaults(); + let document = set_by_path( + &defaults, + "admin.passwordHash", + Value::String("$2b$10$hash".to_owned()), + ) + .expect("passwordHash is writable"); + assert_eq!(validate_document(&document, &defaults), Vec::new()); +} + +#[test] +fn renders_the_document_root_as_a_dollar_sign() { + let document = Value::String("not a document".to_owned()); + assert!(issues(&document).contains(&"$: Expected object, got string".to_owned())); +} + +// --- admin notifications ---------------------------------------------------------------- + +fn notifications(extra: &str) -> Value { + let patch = format!("admin:\n notifications:\n{extra}"); + patched(&patch) +} + +#[test] +fn rejects_retry_max_attempts_below_one() { + let document = notifications(" retry:\n maxAttempts: 0\n"); + assert_eq!( + issues(&document), + vec!["admin.notifications.retry.maxAttempts: maxAttempts must be at least 1"] + ); +} + +#[test] +fn rejects_a_negative_base_delay() { + let document = notifications(" retry:\n baseDelayMs: -1\n"); + assert_eq!( + issues(&document), + vec!["admin.notifications.retry.baseDelayMs: baseDelayMs must be >= 0"] + ); +} + +#[test] +fn rejects_a_retention_below_one_day() { + let document = notifications(" deliveryLogRetentionDays: 0\n"); + assert_eq!( + issues(&document), + vec!["admin.notifications.deliveryLogRetentionDays: deliveryLogRetentionDays must be at least 1"] + ); +} + +#[test] +fn rejects_targets_that_are_not_an_array() { + let document = notifications(" targets: {}\n"); + assert_eq!( + issues(&document), + vec![ + "admin.notifications.targets: Expected array, got object", + "admin.notifications.targets: targets must be an array", + ] + ); +} + +#[test] +fn rejects_a_target_that_is_not_an_object() { + let document = notifications(" targets:\n - 42\n"); + assert_eq!( + issues(&document), + vec!["admin.notifications.targets[0]: target must be an object"] + ); +} + +#[test] +fn rejects_a_target_without_an_id() { + let document = notifications(" targets:\n - type: http\n url: https://a.example\n"); + assert_eq!( + issues(&document), + vec!["admin.notifications.targets[0].id: target id is required"] + ); +} + +#[test] +fn rejects_duplicate_target_ids() { + let document = notifications( + " targets:\n - id: dup\n type: http\n url: https://a.example\n - id: dup\n type: http\n url: https://b.example\n", + ); + assert_eq!( + issues(&document), + vec!["admin.notifications.targets[1].id: target id must be unique"] + ); +} + +#[test] +fn rejects_an_unknown_target_type() { + let document = notifications(" targets:\n - id: a\n type: carrier-pigeon\n"); + assert_eq!( + issues(&document), + vec!["admin.notifications.targets[0].type: type must be http, discord, slack, or telegram"] + ); +} + +#[test] +fn rejects_a_non_boolean_enabled_flag() { + let document = + notifications(" targets:\n - id: a\n type: telegram\n enabled: yes-please\n botToken: t\n chatId: c\n"); + assert_eq!( + issues(&document), + vec!["admin.notifications.targets[0].enabled: enabled must be a boolean"] + ); +} + +#[test] +fn requires_telegram_credentials() { + let document = notifications(" targets:\n - id: t1\n type: telegram\n"); + assert_eq!( + issues(&document), + vec![ + "admin.notifications.targets[0].botToken: botToken is required for telegram targets", + "admin.notifications.targets[0].chatId: chatId is required for telegram targets", + ] + ); +} + +#[test] +fn requires_a_url_for_webhook_targets() { + let document = notifications(" targets:\n - id: w1\n type: http\n"); + assert_eq!( + issues(&document), + vec!["admin.notifications.targets[0].url: url is required for webhook targets"] + ); +} + +#[test] +fn rejects_a_webhook_url_with_a_non_http_protocol() { + let document = notifications( + " targets:\n - id: w1\n type: http\n url: ftp://bad.example\n", + ); + assert_eq!( + issues(&document), + vec!["admin.notifications.targets[0].url: url must use http or https"] + ); +} + +#[test] +fn rejects_a_url_that_does_not_parse() { + let document = + notifications(" targets:\n - id: w1\n type: slack\n url: not a url\n"); + assert_eq!( + issues(&document), + vec!["admin.notifications.targets[0].url: url must be a valid URL"] + ); +} + +#[test] +fn accepts_a_well_formed_http_target() { + let document = notifications( + " targets:\n - id: w1\n type: http\n enabled: true\n url: https://a.example/hook\n", + ); + assert_eq!(issues(&document), Vec::::new()); +} + +#[test] +fn merge_deep_right_replaces_arrays_and_null() { + let defaults: Value = + serde_yaml::from_str("a:\n b: 1\n list:\n - 1\n - 2\n keep: x\n").expect("doc"); + let user: Value = serde_yaml::from_str("a:\n list:\n - 9\n keep: null\n").expect("doc"); + let merged = merge_deep_right(&defaults, &user); + assert_eq!( + nostream_config::path::get_by_path(&merged, "a.list").expect("path").cloned(), + Some(serde_yaml::from_str::("[9]").expect("seq")) + ); + assert_eq!( + nostream_config::path::get_by_path(&merged, "a.keep").expect("path").cloned(), + Some(Value::Null) + ); + assert_eq!( + nostream_config::path::get_by_path(&merged, "a.b").expect("path").cloned(), + Some(Value::Number(1.into())) + ); +} From 4895d678b61dce12431a2f9f8c274576221ae7b7 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 15:22:38 -0400 Subject: [PATCH 04/32] style(rust): apply cargo fmt --- crates/nostream-config/src/guided.rs | 38 +++++++-- crates/nostream-config/src/lib.rs | 2 +- crates/nostream-config/src/load.rs | 5 +- crates/nostream-config/src/notifications.rs | 9 +- crates/nostream-config/src/path.rs | 5 +- crates/nostream-config/src/validate.rs | 85 ++++++++++++++----- crates/nostream-config/src/value.rs | 16 +++- .../nostream-config/tests/defaults_parity.rs | 33 +++++-- .../tests/defaults_roundtrip.rs | 6 +- crates/nostream-config/tests/guided.rs | 19 +++-- crates/nostream-config/tests/load.rs | 22 +++-- .../tests/notifications_merge.rs | 22 +++-- crates/nostream-config/tests/path_editor.rs | 35 +++++--- crates/nostream-config/tests/redaction.rs | 7 +- crates/nostream-config/tests/ts_parity.rs | 33 +++++-- crates/nostream-config/tests/validator.rs | 68 ++++++++++----- crates/nostream-core/src/constants.rs | 3 +- crates/nostream-core/src/event_store.rs | 4 +- crates/nostream-core/src/lib.rs | 2 +- crates/nostream-core/src/nip42.rs | 6 +- crates/nostream-core/src/nip43.rs | 6 +- crates/nostream-core/src/nip98.rs | 8 +- crates/nostream-core/src/report_scoring.rs | 5 +- 23 files changed, 335 insertions(+), 104 deletions(-) diff --git a/crates/nostream-config/src/guided.rs b/crates/nostream-config/src/guided.rs index 3495ee9bd..5402a2a35 100644 --- a/crates/nostream-config/src/guided.rs +++ b/crates/nostream-config/src/guided.rs @@ -153,7 +153,12 @@ fn build_categories() -> Vec { "relay-info", "Relay Info", vec![ - validated_text("Relay URL", "info.relay_url", "wss://relay.example.com", NonEmpty), + validated_text( + "Relay URL", + "info.relay_url", + "wss://relay.example.com", + NonEmpty, + ), validated_text("Relay name", "info.name", "relay.example.com", NonEmpty), text( "Description", @@ -161,7 +166,11 @@ fn build_categories() -> Vec { "A nostr relay written in Typescript.", ), text("Contact", "info.contact", "mailto:operator@your-domain.com"), - text("Relay pubkey (hex)", "info.pubkey", "replace-with-your-pubkey-in-hex"), + text( + "Relay pubkey (hex)", + "info.pubkey", + "replace-with-your-pubkey-in-hex", + ), ], ), category( @@ -197,7 +206,12 @@ fn build_categories() -> Vec { Number, SafeNonNegativeInteger, ), - validated("Worker count", "workers.count", Number, SafeNonNegativeInteger), + validated( + "Worker count", + "workers.count", + Number, + SafeNonNegativeInteger, + ), ], ), category( @@ -272,13 +286,21 @@ fn build_categories() -> Vec { category( "retention", "Retention", - vec![field("Event retention (days)", "limits.event.retention.maxDays", Number)], + vec![field( + "Event retention (days)", + "limits.event.retention.maxDays", + Number, + )], ), category( "nip-features", "NIP Features", vec![ - select("NIP-05 mode", "nip05.mode", &["enabled", "passive", "disabled"]), + select( + "NIP-05 mode", + "nip05.mode", + &["enabled", "passive", "disabled"], + ), string_array( "NIP-05 domain whitelist", "nip05.domainWhitelist", @@ -298,7 +320,11 @@ fn build_categories() -> Vec { SafeNonNegativeInteger, ), field("Enable Web of Trust", "wot.enabled", Boolean), - text("WoT seed pubkey (hex)", "wot.seedPubkey", "Relay owner pubkey in hex"), + text( + "WoT seed pubkey (hex)", + "wot.seedPubkey", + "Relay owner pubkey in hex", + ), validated( "WoT minimum followers", "wot.minimumFollowers", diff --git a/crates/nostream-config/src/lib.rs b/crates/nostream-config/src/lib.rs index 6863ee215..90a4a8a7e 100644 --- a/crates/nostream-config/src/lib.rs +++ b/crates/nostream-config/src/lib.rs @@ -33,7 +33,7 @@ pub mod value; pub use error::ConfigError; pub use issue::Issue; -pub use load::{load, load_defaults, load_user_settings, merge, LoadOutcome}; +pub use load::{LoadOutcome, load, load_defaults, load_user_settings, merge}; pub use paths::SettingsPaths; pub use settings::Settings; pub use validate::{validate, validate_document}; diff --git a/crates/nostream-config/src/load.rs b/crates/nostream-config/src/load.rs index 93ccff192..0e6b1ef35 100644 --- a/crates/nostream-config/src/load.rs +++ b/crates/nostream-config/src/load.rs @@ -41,7 +41,10 @@ pub fn load_document(path: &Path) -> Result { path: path.to_path_buf(), source, })?; - if path.extension().is_some_and(|extension| extension == "json") { + if path + .extension() + .is_some_and(|extension| extension == "json") + { let document: Value = serde_json::from_str(&raw).map_err(|source| ConfigError::ParseJson { path: path.to_path_buf(), diff --git a/crates/nostream-config/src/notifications.rs b/crates/nostream-config/src/notifications.rs index 6b73039b7..d0676a968 100644 --- a/crates/nostream-config/src/notifications.rs +++ b/crates/nostream-config/src/notifications.rs @@ -47,7 +47,10 @@ pub struct OperatorNotificationEventToggles { skip_serializing_if = "Option::is_none" )] pub admission_invoice_created: Option, - #[serde(rename = "admission.invoice.paid", skip_serializing_if = "Option::is_none")] + #[serde( + rename = "admission.invoice.paid", + skip_serializing_if = "Option::is_none" + )] pub admission_invoice_paid: Option, #[serde( rename = "admission.invoice.failed", @@ -232,7 +235,9 @@ fn merge_target_secrets( if is_redacted_secret(incoming.bot_token.as_deref()) { merged.bot_token = existing.and_then(|target| target.bot_token.clone()); } else if incoming.bot_token.is_none() - && existing.and_then(|target| target.bot_token.clone()).is_some() + && existing + .and_then(|target| target.bot_token.clone()) + .is_some() { merged.bot_token = existing.and_then(|target| target.bot_token.clone()); } diff --git a/crates/nostream-config/src/path.rs b/crates/nostream-config/src/path.rs index efd7f3975..8e1aeb94e 100644 --- a/crates/nostream-config/src/path.rs +++ b/crates/nostream-config/src/path.rs @@ -253,7 +253,10 @@ fn path_exists_in_schema(schema: &Value, tokens: &[PathToken]) -> bool { } /// Checks a path against the defaults document used as schema. -pub fn validate_path_against_defaults(defaults: &Value, path: &str) -> Result, PathError> { +pub fn validate_path_against_defaults( + defaults: &Value, + path: &str, +) -> Result, PathError> { let tokens = parse_path(path)?; if path_exists_in_schema(defaults, &tokens) { return Ok(Vec::new()); diff --git a/crates/nostream-config/src/validate.rs b/crates/nostream-config/src/validate.rs index 165b14c38..a0600cbcc 100644 --- a/crates/nostream-config/src/validate.rs +++ b/crates/nostream-config/src/validate.rs @@ -47,7 +47,8 @@ pub fn validate_document(candidate: &Value, defaults: &Value) -> Vec { } let strategy = key_path(candidate, &["limits", "rateLimiter", "strategy"]); - if truthy_opt(strategy) && !matches!(strategy, Some(Value::String(name)) if name == "ewma" || name == "sliding_window") + if truthy_opt(strategy) + && !matches!(strategy, Some(Value::String(name)) if name == "ewma" || name == "sliding_window") { issues.push(Issue::new( "limits.rateLimiter.strategy", @@ -126,17 +127,41 @@ pub fn validate_document(candidate: &Value, defaults: &Value) -> Vec { } if let Some(limits) = key(candidate, "limits") { - validate_rate_limit_periods(key_path(limits, &["event", "rateLimits"]), "limits.event.rateLimits", &mut issues); - validate_rate_limit_periods(key_path(limits, &["message", "rateLimits"]), "limits.message.rateLimits", &mut issues); - validate_rate_limit_periods(key_path(limits, &["connection", "rateLimits"]), "limits.connection.rateLimits", &mut issues); - validate_rate_limit_periods(key_path(limits, &["invoice", "rateLimits"]), "limits.invoice.rateLimits", &mut issues); + validate_rate_limit_periods( + key_path(limits, &["event", "rateLimits"]), + "limits.event.rateLimits", + &mut issues, + ); + validate_rate_limit_periods( + key_path(limits, &["message", "rateLimits"]), + "limits.message.rateLimits", + &mut issues, + ); + validate_rate_limit_periods( + key_path(limits, &["connection", "rateLimits"]), + "limits.connection.rateLimits", + &mut issues, + ); + validate_rate_limit_periods( + key_path(limits, &["invoice", "rateLimits"]), + "limits.invoice.rateLimits", + &mut issues, + ); validate_rate_limit_periods( key_path(limits, &["admissionCheck", "rateLimits"]), "limits.admissionCheck.rateLimits", &mut issues, ); - validate_rate_limit_periods(key_path(limits, &["invite", "rateLimits"]), "limits.invite.rateLimits", &mut issues); - validate_rate_limit_periods(key_path(limits, &["admin", "rateLimits"]), "limits.admin.rateLimits", &mut issues); + validate_rate_limit_periods( + key_path(limits, &["invite", "rateLimits"]), + "limits.invite.rateLimits", + &mut issues, + ); + validate_rate_limit_periods( + key_path(limits, &["admin", "rateLimits"]), + "limits.admin.rateLimits", + &mut issues, + ); validate_rate_limit_periods( key_path(limits, &["admin", "loginRateLimits"]), "limits.admin.loginRateLimits", @@ -158,7 +183,11 @@ fn is_event_store_backend(value: Option<&Value>) -> bool { matches!(value, Some(Value::String(name)) if EventStoreBackend::parse(name).is_some()) } -fn validate_rate_limit_periods(rate_limits: Option<&Value>, path_prefix: &str, issues: &mut Vec) { +fn validate_rate_limit_periods( + rate_limits: Option<&Value>, + path_prefix: &str, + issues: &mut Vec, +) { let Some(Value::Sequence(entries)) = rate_limits else { return; }; @@ -218,7 +247,12 @@ fn validate_shape( let mut local_issues = Vec::new(); let mut local_path = path.clone(); local_path.push(PathToken::Index(index)); - validate_shape(schema_entry, Some(entry), &mut local_path, &mut local_issues); + validate_shape( + schema_entry, + Some(entry), + &mut local_path, + &mut local_issues, + ); local_issues.is_empty() }); @@ -251,7 +285,10 @@ fn validate_shape( if !schema_mapping.contains_key(key) { let mut issue_path = path.clone(); issue_path.push(PathToken::Key(key.to_owned())); - issues.push(Issue::new(format_path_tokens(&issue_path), "Unknown setting key")); + issues.push(Issue::new( + format_path_tokens(&issue_path), + "Unknown setting key", + )); } } @@ -275,7 +312,11 @@ fn validate_shape( if !matches!(schema, Value::Null) && js_typeof(schema) != js_typeof(candidate) { issues.push(Issue::new( rendered, - format!("Expected {}, got {}", js_typeof(schema), js_typeof(candidate)), + format!( + "Expected {}, got {}", + js_typeof(schema), + js_typeof(candidate) + ), )); } } @@ -341,7 +382,10 @@ fn validate_admin_notifications(candidate: &Value) -> Vec { match key(target, "id") { Some(Value::String(id)) if !id.trim().is_empty() => { if target_ids.iter().any(|existing| existing == id) { - issues.push(Issue::new(format!("{prefix}.id"), "target id must be unique")); + issues.push(Issue::new( + format!("{prefix}.id"), + "target id must be unique", + )); } else { target_ids.push(id.clone()); } @@ -367,7 +411,10 @@ fn validate_admin_notifications(candidate: &Value) -> Vec { if let Some(enabled) = key(target, "enabled") && !matches!(enabled, Value::Bool(_)) { - issues.push(Issue::new(format!("{prefix}.enabled"), "enabled must be a boolean")); + issues.push(Issue::new( + format!("{prefix}.enabled"), + "enabled must be a boolean", + )); } match target_type { @@ -393,9 +440,10 @@ fn validate_admin_notifications(candidate: &Value) -> Vec { format!("{prefix}.url"), "url must use http or https", )), - UrlCheck::Invalid => { - issues.push(Issue::new(format!("{prefix}.url"), "url must be a valid URL")) - } + UrlCheck::Invalid => issues.push(Issue::new( + format!("{prefix}.url"), + "url must be a valid URL", + )), } } _ => issues.push(Issue::new( @@ -443,10 +491,7 @@ fn check_webhook_url(raw: &str) -> UrlCheck { let Some(authority) = trimmed[colon + 1..].strip_prefix("//") else { return UrlCheck::Invalid; }; - let host = authority - .split(['/', '?', '#']) - .next() - .unwrap_or_default(); + let host = authority.split(['/', '?', '#']).next().unwrap_or_default(); if host.is_empty() { return UrlCheck::Invalid; } diff --git a/crates/nostream-config/src/value.rs b/crates/nostream-config/src/value.rs index 3818dec9a..42fde8768 100644 --- a/crates/nostream-config/src/value.rs +++ b/crates/nostream-config/src/value.rs @@ -18,7 +18,9 @@ pub fn truthy(value: &Value) -> bool { match value { Value::Null => false, Value::Bool(boolean) => *boolean, - Value::Number(number) => number.as_f64().is_some_and(|float| float != 0.0 && !float.is_nan()), + Value::Number(number) => number + .as_f64() + .is_some_and(|float| float != 0.0 && !float.is_nan()), Value::String(string) => !string.is_empty(), Value::Sequence(_) | Value::Mapping(_) | Value::Tagged(_) => true, } @@ -65,12 +67,16 @@ pub fn to_number(value: &Value) -> Option { /// `value >= bound`, with `undefined` and non-numeric values comparing false. pub fn number_ge(value: Option<&Value>, bound: f64) -> bool { - value.and_then(to_number).is_some_and(|number| number >= bound) + value + .and_then(to_number) + .is_some_and(|number| number >= bound) } /// `value <= bound`, with `undefined` and non-numeric values comparing false. pub fn number_le(value: Option<&Value>, bound: f64) -> bool { - value.and_then(to_number).is_some_and(|number| number <= bound) + value + .and_then(to_number) + .is_some_and(|number| number <= bound) } /// `left >= right`, with either side missing or non-numeric comparing false. @@ -91,7 +97,9 @@ pub fn compare_le(left: Option<&Value>, right: Option<&Value>) -> bool { /// `value < bound`. pub fn number_lt(value: Option<&Value>, bound: f64) -> bool { - value.and_then(to_number).is_some_and(|number| number < bound) + value + .and_then(to_number) + .is_some_and(|number| number < bound) } /// A property read (`value.name`); `None` means `undefined`. diff --git a/crates/nostream-config/tests/defaults_parity.rs b/crates/nostream-config/tests/defaults_parity.rs index c5297834b..bd0002ac5 100644 --- a/crates/nostream-config/tests/defaults_parity.rs +++ b/crates/nostream-config/tests/defaults_parity.rs @@ -19,7 +19,7 @@ fn defaults() -> Value { fn value(document: &Value, path: &str) -> Value { get_by_path(document, path) .expect("the path parses") - .unwrap_or_else(|| panic!("{path} exists")) + .unwrap_or_else(|| panic!("{path} exists")) .clone() } @@ -42,7 +42,11 @@ fn nip43_defaults_are_the_capture_mint_defaults() { assert_value(&defaults, "nip43.inviteCodeExpirySeconds", number(600)); assert_value(&defaults, "nip43.defaultMaxUses", number(1)); assert_value(&defaults, "nip43.allowInviteRequests", boolean(false)); - assert_value(&defaults, "nip43.inviteRequestWhitelist", Value::Sequence(vec![])); + assert_value( + &defaults, + "nip43.inviteRequestWhitelist", + Value::Sequence(vec![]), + ); } #[test] @@ -55,7 +59,11 @@ fn nip43_invite_rate_limit_is_per_pubkey() { panic!("limits.invite.rateLimits is a sequence"); }; assert_eq!(entries.len(), 1); - assert_value(&defaults, "limits.invite.rateLimits[0].period", number(3_600_000)); + assert_value( + &defaults, + "limits.invite.rateLimits[0].period", + number(3_600_000), + ); assert_value(&defaults, "limits.invite.rateLimits[0].rate", number(5)); } @@ -96,7 +104,11 @@ fn wot_defaults_are_disabled() { fn nip56_defaults_are_disabled() { let defaults = defaults(); assert_value(&defaults, "nip56.enabled", boolean(false)); - assert_value(&defaults, "nip56.trustedModerators", Value::Sequence(vec![])); + assert_value( + &defaults, + "nip56.trustedModerators", + Value::Sequence(vec![]), + ); } #[test] @@ -134,12 +146,17 @@ fn a_nip66_user_block_overrides_only_what_it_names() { #[test] fn a_wot_user_block_keeps_unmentioned_fields() { let defaults = defaults(); - let user: Value = - serde_yaml::from_str("wot:\n enabled: true\n seedPubkey: abc123\n minimumFollowers: 3\n") - .expect("user document parses"); + let user: Value = serde_yaml::from_str( + "wot:\n enabled: true\n seedPubkey: abc123\n minimumFollowers: 3\n", + ) + .expect("user document parses"); let merged = merge(&defaults, &user); assert_value(&merged, "wot.enabled", boolean(true)); - assert_value(&merged, "wot.seedPubkey", Value::String("abc123".to_owned())); + assert_value( + &merged, + "wot.seedPubkey", + Value::String("abc123".to_owned()), + ); assert_value(&merged, "wot.minimumFollowers", number(3)); assert_value(&merged, "wot.refreshIntervalHours", number(24)); } diff --git a/crates/nostream-config/tests/defaults_roundtrip.rs b/crates/nostream-config/tests/defaults_roundtrip.rs index 012c5b8f8..43f626b94 100644 --- a/crates/nostream-config/tests/defaults_roundtrip.rs +++ b/crates/nostream-config/tests/defaults_roundtrip.rs @@ -22,7 +22,11 @@ fn defaults() -> Value { fn the_shipped_defaults_validate_with_no_issues() { let defaults = defaults(); let issues = validate_document(&defaults, &defaults); - assert_eq!(issues, Vec::new(), "the shipped defaults must validate clean"); + assert_eq!( + issues, + Vec::new(), + "the shipped defaults must validate clean" + ); } #[test] diff --git a/crates/nostream-config/tests/guided.rs b/crates/nostream-config/tests/guided.rs index 4254b737a..e5e17521f 100644 --- a/crates/nostream-config/tests/guided.rs +++ b/crates/nostream-config/tests/guided.rs @@ -21,8 +21,8 @@ fn exports_guided_categories_for_admin_and_cli_use() { #[test] fn looks_up_a_field_by_category_and_path() { - let field = get_guided_setting_field("limits", "limits.rateLimiter.strategy") - .expect("strategy field"); + let field = + get_guided_setting_field("limits", "limits.rateLimiter.strategy").expect("strategy field"); assert_eq!(field.field_type, GuidedSettingFieldType::Select); assert_eq!(field.options, vec!["ewma", "sliding_window"]); } @@ -52,12 +52,18 @@ fn validates_guided_numeric_fields() { require_safe_non_negative_integer_setting_value("bad"), Some("Value must be a non-negative integer") ); - assert_eq!(require_safe_non_negative_integer_setting_value("2048"), None); + assert_eq!( + require_safe_non_negative_integer_setting_value("2048"), + None + ); assert_eq!( require_safe_non_negative_integer_setting_value("9007199254740992"), Some("Value must be a safe integer") ); - assert_eq!(require_non_empty_setting_value(" "), Some("Value is required")); + assert_eq!( + require_non_empty_setting_value(" "), + Some("Value is required") + ); assert_eq!(require_non_empty_setting_value("relay.example.com"), None); } @@ -71,7 +77,10 @@ fn validators_are_reachable_from_the_field() { Some("Value must be a non-negative integer") ); assert_eq!(validator.validate("524288"), None); - assert_eq!(GuidedSettingValidator::NonEmpty.validate(" "), Some("Value is required")); + assert_eq!( + GuidedSettingValidator::NonEmpty.validate(" "), + Some("Value is required") + ); } #[test] diff --git a/crates/nostream-config/tests/load.rs b/crates/nostream-config/tests/load.rs index e360fffa2..4c03b9a18 100644 --- a/crates/nostream-config/tests/load.rs +++ b/crates/nostream-config/tests/load.rs @@ -5,9 +5,9 @@ use std::fs; use std::path::{Path, PathBuf}; +use nostream_config::SettingsPaths; use nostream_config::load::{load, load_user_settings, merge}; use nostream_config::path::get_by_path; -use nostream_config::SettingsPaths; use serde_yaml::Value; use tempfile::TempDir; @@ -62,8 +62,7 @@ fn a_yaml_override_is_deep_merged() { assert!(!outcome.used_defaults); assert_eq!(outcome.settings.info.name, "my-relay"); assert_eq!( - outcome.settings.info.relay_url, - "wss://nostream.your-domain.com", + outcome.settings.info.relay_url, "wss://nostream.your-domain.com", "unmentioned fields keep their defaults" ); assert_eq!( @@ -106,7 +105,11 @@ fn a_broken_yaml_file_falls_back_with_an_issue() { assert!(outcome.used_defaults); assert_eq!(outcome.issues.len(), 1); assert_eq!(outcome.issues[0].path, "$"); - assert!(outcome.issues[0].message.contains("failed to load user settings")); + assert!( + outcome.issues[0] + .message + .contains("failed to load user settings") + ); } #[test] @@ -134,7 +137,11 @@ fn yaml_takes_precedence_over_the_deprecated_json_file() { "settings.json", "{\"info\": {\"name\": \"legacy\"}}", ); - write(config_dir.path(), "settings.yaml", "info:\n name: current\n"); + write( + config_dir.path(), + "settings.yaml", + "info:\n name: current\n", + ); let user = load_user_settings(config_dir.path()) .expect("the file loads") @@ -174,7 +181,10 @@ fn a_config_dir_override_wins_over_the_environment() { let config_dir = TempDir::new().expect("tempdir"); let resolved = SettingsPaths::from_config_dir(Path::new("/srv/relay"), config_dir.path()); assert_eq!(resolved.config_base_dir, config_dir.path()); - assert_eq!(resolved.settings_file, config_dir.path().join("settings.yaml")); + assert_eq!( + resolved.settings_file, + config_dir.path().join("settings.yaml") + ); assert_eq!( resolved.legacy_settings_file, config_dir.path().join("settings.json") diff --git a/crates/nostream-config/tests/notifications_merge.rs b/crates/nostream-config/tests/notifications_merge.rs index d3ed66a9c..cae8696c0 100644 --- a/crates/nostream-config/tests/notifications_merge.rs +++ b/crates/nostream-config/tests/notifications_merge.rs @@ -25,7 +25,9 @@ fn merges_patch_values_and_preserves_redacted_webhook_secrets() { let current = current_with_discord(); let next = merge_admin_notifications_patch( ¤t, - &patch("targets:\n - id: discord-1\n type: discord\n enabled: false\n url: '***'\n"), + &patch( + "targets:\n - id: discord-1\n type: discord\n enabled: false\n url: '***'\n", + ), ); assert_eq!(next.targets[0].enabled, Some(false)); @@ -42,7 +44,9 @@ fn does_not_persist_redacted_placeholders_for_new_targets() { ); let next = merge_admin_notifications_patch( ¤t, - &patch("targets:\n - id: telegram-new\n type: telegram\n enabled: true\n botToken: '***'\n chatId: '123'\n"), + &patch( + "targets:\n - id: telegram-new\n type: telegram\n enabled: true\n botToken: '***'\n chatId: '123'\n", + ), ); assert_eq!(next.targets[0].bot_token, None); @@ -67,7 +71,9 @@ fn a_patch_merges_events_and_retry_and_replaces_scalars() { let current = current_with_discord(); let next = merge_admin_notifications_patch( ¤t, - &patch("enabled: false\nevents:\n relay.restarted: true\nretry:\n maxAttempts: 9\ndeliveryLogRetentionDays: 7\n"), + &patch( + "enabled: false\nevents:\n relay.restarted: true\nretry:\n maxAttempts: 9\ndeliveryLogRetentionDays: 7\n", + ), ); assert!(!next.enabled); @@ -76,7 +82,11 @@ fn a_patch_merges_events_and_retry_and_replaces_scalars() { assert_eq!(next.retry.max_attempts, Some(9)); assert_eq!(next.retry.base_delay_ms, Some(1000)); assert_eq!(next.delivery_log_retention_days, Some(7)); - assert_eq!(next.targets.len(), 1, "a patch without targets keeps the current ones"); + assert_eq!( + next.targets.len(), + 1, + "a patch without targets keeps the current ones" + ); } #[test] @@ -84,7 +94,9 @@ fn a_patch_replaces_targets_wholesale() { let current = current_with_discord(); let next = merge_admin_notifications_patch( ¤t, - &patch("targets:\n - id: replacement\n type: http\n enabled: true\n url: https://a.example/hook\n"), + &patch( + "targets:\n - id: replacement\n type: http\n enabled: true\n url: https://a.example/hook\n", + ), ); assert_eq!(next.targets.len(), 1); diff --git a/crates/nostream-config/tests/path_editor.rs b/crates/nostream-config/tests/path_editor.rs index 81e090610..36b85d834 100644 --- a/crates/nostream-config/tests/path_editor.rs +++ b/crates/nostream-config/tests/path_editor.rs @@ -3,7 +3,7 @@ use std::fs; use nostream_config::path::{ - PathError, ParsedValue, get_by_path, parse_path, parse_typed_json, parse_value, set_by_path, + ParsedValue, PathError, get_by_path, parse_path, parse_typed_json, parse_value, set_by_path, validate_path_against_defaults, }; use serde_yaml::Value; @@ -24,7 +24,10 @@ fn parses_primitive_values() { assert_eq!(parse_value("42"), ParsedValue::Number(42)); assert_eq!(parse_value("42n"), ParsedValue::BigInt("42".to_owned())); assert_eq!(parse_value("null"), ParsedValue::Null); - assert_eq!(parse_value("hello"), ParsedValue::String("hello".to_owned())); + assert_eq!( + parse_value("hello"), + ParsedValue::String("hello".to_owned()) + ); } #[test] @@ -59,15 +62,17 @@ fn sets_and_gets_dot_path_values() { get_by_path(&updated, "payments.enabled").expect("get"), Some(&Value::Bool(true)) ); - assert_eq!(get_by_path(&updated, "payments.processor").expect("get"), None); + assert_eq!( + get_by_path(&updated, "payments.processor").expect("get"), + None + ); } #[test] fn supports_indexed_path_syntax() { - let document: Value = serde_yaml::from_str( - "limits:\n event:\n content:\n - maxLength: 100\n", - ) - .expect("doc"); + let document: Value = + serde_yaml::from_str("limits:\n event:\n content:\n - maxLength: 100\n") + .expect("doc"); let updated = set_by_path( &document, "limits.event.content[0].maxLength", @@ -92,7 +97,11 @@ fn rejects_malformed_path_syntax() { #[test] fn rejects_reserved_prototype_keys() { let document: Value = serde_yaml::from_str("payments:\n enabled: false\n").expect("doc"); - for path in ["__proto__.enabled", "constructor.enabled", "prototype.enabled"] { + for path in [ + "__proto__.enabled", + "constructor.enabled", + "prototype.enabled", + ] { let key = path.split('.').next().expect("key"); assert_eq!( set_by_path(&document, path, Value::Bool(true)), @@ -188,7 +197,13 @@ fn a_key_followed_by_an_index_becomes_an_array() { #[test] fn reading_a_missing_path_is_none() { let document: Value = serde_yaml::from_str("info:\n name: relay\n").expect("doc"); - assert_eq!(get_by_path(&document, "info.relay_url").expect("path"), None); - assert_eq!(get_by_path(&document, "network.maxPayloadSize").expect("path"), None); + assert_eq!( + get_by_path(&document, "info.relay_url").expect("path"), + None + ); + assert_eq!( + get_by_path(&document, "network.maxPayloadSize").expect("path"), + None + ); assert_eq!(get_by_path(&document, "info[0]").expect("path"), None); } diff --git a/crates/nostream-config/tests/redaction.rs b/crates/nostream-config/tests/redaction.rs index 72f8acfba..7b59f6ebc 100644 --- a/crates/nostream-config/tests/redaction.rs +++ b/crates/nostream-config/tests/redaction.rs @@ -37,7 +37,8 @@ fn redacts_password_hashes_and_nested_secrets() { #[test] fn leaves_empty_secret_values_unchanged() { - let input = document("mirroring:\n static:\n - address: wss://mirror.example\n secret: ''\n"); + let input = + document("mirroring:\n static:\n - address: wss://mirror.example\n secret: ''\n"); let redacted = redact_settings_secrets(&input); assert_eq!( get_by_path(&redacted, "mirroring.static[0].secret") @@ -53,7 +54,9 @@ fn identifies_sensitive_and_write_protected_paths() { assert!(is_sensitive_settings_path("mirroring.static[0].secret")); assert!(!is_sensitive_settings_path("payments.enabled")); assert!(is_write_protected_settings_path("admin.passwordHash")); - assert!(!is_write_protected_settings_path("mirroring.static[0].secret")); + assert!(!is_write_protected_settings_path( + "mirroring.static[0].secret" + )); } #[test] diff --git a/crates/nostream-config/tests/ts_parity.rs b/crates/nostream-config/tests/ts_parity.rs index 2eeb2c5ab..b57a4d169 100644 --- a/crates/nostream-config/tests/ts_parity.rs +++ b/crates/nostream-config/tests/ts_parity.rs @@ -100,7 +100,9 @@ fn delete_by_path(document: &Value, path: &str) -> Value { for token in &tokens[..tokens.len() - 1] { current = match token { PathToken::Key(key) => match current { - Value::Mapping(mapping) => mapping.get_mut(key.as_str()).expect("parent key exists"), + Value::Mapping(mapping) => { + mapping.get_mut(key.as_str()).expect("parent key exists") + } _ => panic!("parent of {path} is not a mapping"), }, PathToken::Index(index) => match current { @@ -131,7 +133,10 @@ fn apply_case(case: &Case, defaults: &Value) -> Vec { "set" => set_by_path( &document, &mutation.path, - mutation.value.clone().expect("a set mutation carries a value"), + mutation + .value + .clone() + .expect("a set mutation carries a value"), ) .expect("set mutation applies"), "delete" => delete_by_path(&document, &mutation.path), @@ -191,7 +196,13 @@ fn fixture_metadata_names_the_capture() { assert_eq!(fixture.deferred.diff, "+212/-0"); assert_eq!(fixture.deferred.files.len(), 5); assert!(!fixture.subject.generated.is_empty()); - assert!(fixture.subject.files.iter().any(|file| file == "src/utils/settings-config.ts")); + assert!( + fixture + .subject + .files + .iter() + .any(|file| file == "src/utils/settings-config.ts") + ); } #[test] @@ -202,7 +213,11 @@ fn case_names_are_unique() { .iter() .map(|case| case.name.as_str()) .collect::>(); - assert_eq!(names.len(), fixture.cases.len(), "duplicate case names in the fixture"); + assert_eq!( + names.len(), + fixture.cases.len(), + "duplicate case names in the fixture" + ); } #[test] @@ -276,10 +291,16 @@ fn the_only_exempt_path_is_event_store_backend() { for case in &fixture.cases { if case.rust_exempt_paths.is_empty() { - assert!(case.exempt_reason.is_none(), "`{}` has a reason but no exemption", case.name); + assert!( + case.exempt_reason.is_none(), + "`{}` has a reason but no exemption", + case.name + ); } else { assert!( - case.exempt_reason.as_deref().is_some_and(|reason| !reason.is_empty()), + case.exempt_reason + .as_deref() + .is_some_and(|reason| !reason.is_empty()), "`{}` exempts a path without recording why", case.name ); diff --git a/crates/nostream-config/tests/validator.rs b/crates/nostream-config/tests/validator.rs index 78c314d75..7f01a5d5f 100644 --- a/crates/nostream-config/tests/validator.rs +++ b/crates/nostream-config/tests/validator.rs @@ -61,7 +61,8 @@ fn basic_required_fields_are_reported() { #[test] fn rejects_a_non_positive_period_on_limits_event_rate_limits() { - let document = patched("limits:\n event:\n rateLimits:\n - period: 0\n rate: 1\n"); + let document = + patched("limits:\n event:\n rateLimits:\n - period: 0\n rate: 1\n"); assert_eq!( paths(&document), vec!["limits.event.rateLimits[0].period".to_owned()] @@ -100,8 +101,9 @@ fn rejects_a_non_positive_period_on_limits_invoice_rate_limits() { #[test] fn rejects_a_non_positive_period_on_limits_admission_check_rate_limits() { - let document = - patched("limits:\n admissionCheck:\n rateLimits:\n - period: 0\n rate: 1\n"); + let document = patched( + "limits:\n admissionCheck:\n rateLimits:\n - period: 0\n rate: 1\n", + ); assert_eq!( paths(&document), vec!["limits.admissionCheck.rateLimits[0].period".to_owned()] @@ -110,7 +112,8 @@ fn rejects_a_non_positive_period_on_limits_admission_check_rate_limits() { #[test] fn rejects_a_non_positive_period_on_limits_invite_rate_limits() { - let document = patched("limits:\n invite:\n rateLimits:\n - period: 0\n rate: 1\n"); + let document = + patched("limits:\n invite:\n rateLimits:\n - period: 0\n rate: 1\n"); assert_eq!( paths(&document), vec!["limits.invite.rateLimits[0].period".to_owned()] @@ -119,7 +122,8 @@ fn rejects_a_non_positive_period_on_limits_invite_rate_limits() { #[test] fn rejects_a_non_positive_period_on_limits_admin_rate_limits() { - let document = patched("limits:\n admin:\n rateLimits:\n - period: 0\n rate: 1\n"); + let document = + patched("limits:\n admin:\n rateLimits:\n - period: 0\n rate: 1\n"); assert_eq!( paths(&document), vec!["limits.admin.rateLimits[0].period".to_owned()] @@ -147,9 +151,15 @@ fn accepts_a_positive_period() { fn rejects_a_null_entry_in_a_rate_limit_array_without_throwing() { let document = patched("limits:\n event:\n rateLimits:\n - null\n"); let reported = issues(&document); - assert!(reported.contains(&"limits.event.rateLimits[0]: rateLimits entry must be an object".to_owned())); assert!( - reported.contains(&"limits.event.rateLimits[0]: Array element does not match expected schema shape".to_owned()), + reported + .contains(&"limits.event.rateLimits[0]: rateLimits entry must be an object".to_owned()) + ); + assert!( + reported.contains( + &"limits.event.rateLimits[0]: Array element does not match expected schema shape" + .to_owned() + ), "the shape walk also rejects the entry, as the capture's does" ); } @@ -158,9 +168,15 @@ fn rejects_a_null_entry_in_a_rate_limit_array_without_throwing() { fn rejects_a_scalar_entry_in_a_rate_limit_array() { let document = patched("limits:\n event:\n rateLimits:\n - 42\n"); let reported = issues(&document); - assert!(reported.contains(&"limits.event.rateLimits[0]: rateLimits entry must be an object".to_owned())); assert!( - reported.contains(&"limits.event.rateLimits[0]: Array element does not match expected schema shape".to_owned()) + reported + .contains(&"limits.event.rateLimits[0]: rateLimits entry must be an object".to_owned()) + ); + assert!( + reported.contains( + &"limits.event.rateLimits[0]: Array element does not match expected schema shape" + .to_owned() + ) ); } @@ -371,7 +387,10 @@ fn rejects_an_unknown_top_level_key() { #[test] fn rejects_an_unknown_nested_key() { let document = patched("info:\n unknownKey: 1\n"); - assert_eq!(issues(&document), vec!["info.unknownKey: Unknown setting key"]); + assert_eq!( + issues(&document), + vec!["info.unknownKey: Unknown setting key"] + ); } #[test] @@ -458,7 +477,9 @@ fn rejects_a_retention_below_one_day() { let document = notifications(" deliveryLogRetentionDays: 0\n"); assert_eq!( issues(&document), - vec!["admin.notifications.deliveryLogRetentionDays: deliveryLogRetentionDays must be at least 1"] + vec![ + "admin.notifications.deliveryLogRetentionDays: deliveryLogRetentionDays must be at least 1" + ] ); } @@ -485,7 +506,8 @@ fn rejects_a_target_that_is_not_an_object() { #[test] fn rejects_a_target_without_an_id() { - let document = notifications(" targets:\n - type: http\n url: https://a.example\n"); + let document = + notifications(" targets:\n - type: http\n url: https://a.example\n"); assert_eq!( issues(&document), vec!["admin.notifications.targets[0].id: target id is required"] @@ -514,8 +536,9 @@ fn rejects_an_unknown_target_type() { #[test] fn rejects_a_non_boolean_enabled_flag() { - let document = - notifications(" targets:\n - id: a\n type: telegram\n enabled: yes-please\n botToken: t\n chatId: c\n"); + let document = notifications( + " targets:\n - id: a\n type: telegram\n enabled: yes-please\n botToken: t\n chatId: c\n", + ); assert_eq!( issues(&document), vec!["admin.notifications.targets[0].enabled: enabled must be a boolean"] @@ -556,8 +579,9 @@ fn rejects_a_webhook_url_with_a_non_http_protocol() { #[test] fn rejects_a_url_that_does_not_parse() { - let document = - notifications(" targets:\n - id: w1\n type: slack\n url: not a url\n"); + let document = notifications( + " targets:\n - id: w1\n type: slack\n url: not a url\n", + ); assert_eq!( issues(&document), vec!["admin.notifications.targets[0].url: url must be a valid URL"] @@ -579,15 +603,21 @@ fn merge_deep_right_replaces_arrays_and_null() { let user: Value = serde_yaml::from_str("a:\n list:\n - 9\n keep: null\n").expect("doc"); let merged = merge_deep_right(&defaults, &user); assert_eq!( - nostream_config::path::get_by_path(&merged, "a.list").expect("path").cloned(), + nostream_config::path::get_by_path(&merged, "a.list") + .expect("path") + .cloned(), Some(serde_yaml::from_str::("[9]").expect("seq")) ); assert_eq!( - nostream_config::path::get_by_path(&merged, "a.keep").expect("path").cloned(), + nostream_config::path::get_by_path(&merged, "a.keep") + .expect("path") + .cloned(), Some(Value::Null) ); assert_eq!( - nostream_config::path::get_by_path(&merged, "a.b").expect("path").cloned(), + nostream_config::path::get_by_path(&merged, "a.b") + .expect("path") + .cloned(), Some(Value::Number(1.into())) ); } diff --git a/crates/nostream-core/src/constants.rs b/crates/nostream-core/src/constants.rs index c83ed3d35..229712f45 100644 --- a/crates/nostream-core/src/constants.rs +++ b/crates/nostream-core/src/constants.rs @@ -7,4 +7,5 @@ pub const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; pub const SUPPORTED_NIPS: [u16; 4] = [11, 42, 45, 50]; /// The payment processors the settings surface names (`src/constants/base.ts` `PaymentsProcessors`). -pub const PAYMENTS_PROCESSORS: [&str; 6] = ["lnurl", "zebedee", "lnbits", "nodeless", "opennode", "nwc"]; +pub const PAYMENTS_PROCESSORS: [&str; 6] = + ["lnurl", "zebedee", "lnbits", "nodeless", "opennode", "nwc"]; diff --git a/crates/nostream-core/src/event_store.rs b/crates/nostream-core/src/event_store.rs index d245d8de0..3d437869d 100644 --- a/crates/nostream-core/src/event_store.rs +++ b/crates/nostream-core/src/event_store.rs @@ -29,7 +29,9 @@ impl EventStoreBackend { /// Parses a settings value. A non-string or unknown value is `None`. pub fn parse(value: &str) -> Option { - Self::ALL.into_iter().find(|backend| backend.as_str() == value) + Self::ALL + .into_iter() + .find(|backend| backend.as_str() == value) } } diff --git a/crates/nostream-core/src/lib.rs b/crates/nostream-core/src/lib.rs index 1c8c614e1..c6f071adf 100644 --- a/crates/nostream-core/src/lib.rs +++ b/crates/nostream-core/src/lib.rs @@ -63,5 +63,5 @@ pub use event::{Event, EventId, Kind, KindSelector, Pubkey}; pub use event_store::EventStoreBackend; pub use filter::Filter; pub use lifecycle::ShutdownState; -pub use secret::Secret; pub use messages::{ClientMessage, RelayMessage}; +pub use secret::Secret; diff --git a/crates/nostream-core/src/nip42.rs b/crates/nostream-core/src/nip42.rs index a17e0de03..cb6888609 100644 --- a/crates/nostream-core/src/nip42.rs +++ b/crates/nostream-core/src/nip42.rs @@ -15,7 +15,11 @@ pub struct AuthState { /// Checks that a kind-22242 event answers this challenge and is signed by its author. /// /// Phase 1 stub — job 005; will verify the challenge tag, relay tag and signature; see `src/utils/nip42.ts`. -pub fn authenticate(_event: &Event, _challenge: &str, _relay_url: &str) -> Result { +pub fn authenticate( + _event: &Event, + _challenge: &str, + _relay_url: &str, +) -> Result { Err(CoreError::NotImplemented { module: "core::nip42::authenticate", }) diff --git a/crates/nostream-core/src/nip43.rs b/crates/nostream-core/src/nip43.rs index a6dbb5c1b..1ba5a4608 100644 --- a/crates/nostream-core/src/nip43.rs +++ b/crates/nostream-core/src/nip43.rs @@ -16,7 +16,11 @@ pub struct InviteCode { /// Mints a kind-28935 invite code for an authenticated requester. /// /// Phase 1 stub — job 005; will mint and persist an invite code; see `src/utils/nip43-invites.ts`. -pub fn mint_invite(_requester: &Pubkey, _max_uses: u32, _expiry_seconds: u64) -> Result { +pub fn mint_invite( + _requester: &Pubkey, + _max_uses: u32, + _expiry_seconds: u64, +) -> Result { Err(CoreError::NotImplemented { module: "core::nip43::mint_invite", }) diff --git a/crates/nostream-core/src/nip98.rs b/crates/nostream-core/src/nip98.rs index fe65a016c..e7f1df86c 100644 --- a/crates/nostream-core/src/nip98.rs +++ b/crates/nostream-core/src/nip98.rs @@ -17,7 +17,13 @@ pub struct HttpAuth { /// /// Phase 1 stub — job 005; will verify the event, the URL/method tags and the replay window; /// see `src/utils/nip98.ts`. -pub fn verify(_header: &str, _url: &str, _method: &str, _body: &[u8], _max_skew_seconds: i64) -> Result { +pub fn verify( + _header: &str, + _url: &str, + _method: &str, + _body: &[u8], + _max_skew_seconds: i64, +) -> Result { Err(CoreError::NotImplemented { module: "core::nip98::verify", }) diff --git a/crates/nostream-core/src/report_scoring.rs b/crates/nostream-core/src/report_scoring.rs index 2dc69c81c..9dcdeb12e 100644 --- a/crates/nostream-core/src/report_scoring.rs +++ b/crates/nostream-core/src/report_scoring.rs @@ -14,7 +14,10 @@ pub enum ReportWeight { /// 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 { +pub fn score_reporter( + _distance: Option, + _is_trusted_moderator: bool, +) -> Result { Err(crate::error::CoreError::NotImplemented { module: "core::report_scoring::score_reporter", }) From ea3a1de541f5504adeb06009e362617f9cf41faf Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 15:23:46 -0400 Subject: [PATCH 05/32] fix(config): satisfy clippy across the crate --- crates/nostream-config/src/validate.rs | 21 ++++++++------------- crates/nostream-config/tests/load.rs | 2 +- crates/nostream-config/tests/validator.rs | 4 ---- 3 files changed, 9 insertions(+), 18 deletions(-) diff --git a/crates/nostream-config/src/validate.rs b/crates/nostream-config/src/validate.rs index a0600cbcc..690cbae94 100644 --- a/crates/nostream-config/src/validate.rs +++ b/crates/nostream-config/src/validate.rs @@ -169,7 +169,7 @@ pub fn validate_document(candidate: &Value, defaults: &Value) -> Vec { ); } - validate_shape(defaults, Some(candidate), &mut Vec::new(), &mut issues); + validate_shape(defaults, Some(candidate), &[], &mut issues); issues.extend(validate_admin_notifications(candidate)); issues @@ -212,7 +212,7 @@ fn validate_rate_limit_periods( fn validate_shape( schema: &Value, candidate: Option<&Value>, - path: &mut Vec, + path: &[PathToken], issues: &mut Vec, ) { let Some(candidate) = candidate else { @@ -245,19 +245,14 @@ fn validate_shape( for (index, entry) in candidates.iter().enumerate() { let matches_any = schema_entries.iter().any(|schema_entry| { let mut local_issues = Vec::new(); - let mut local_path = path.clone(); + let mut local_path = path.to_vec(); local_path.push(PathToken::Index(index)); - validate_shape( - schema_entry, - Some(entry), - &mut local_path, - &mut local_issues, - ); + validate_shape(schema_entry, Some(entry), &local_path, &mut local_issues); local_issues.is_empty() }); if !matches_any { - let mut issue_path = path.clone(); + let mut issue_path = path.to_vec(); issue_path.push(PathToken::Index(index)); issues.push(Issue::new( format_path_tokens(&issue_path), @@ -283,7 +278,7 @@ fn validate_shape( continue; } if !schema_mapping.contains_key(key) { - let mut issue_path = path.clone(); + let mut issue_path = path.to_vec(); issue_path.push(PathToken::Key(key.to_owned())); issues.push(Issue::new( format_path_tokens(&issue_path), @@ -296,9 +291,9 @@ fn validate_shape( let Some(key) = key.as_str() else { continue; }; - let mut child_path = path.clone(); + let mut child_path = path.to_vec(); child_path.push(PathToken::Key(key.to_owned())); - validate_shape(schema_value, candidates.get(key), &mut child_path, issues); + validate_shape(schema_value, candidates.get(key), &child_path, issues); } } _ => { diff --git a/crates/nostream-config/tests/load.rs b/crates/nostream-config/tests/load.rs index 4c03b9a18..99cc5d25e 100644 --- a/crates/nostream-config/tests/load.rs +++ b/crates/nostream-config/tests/load.rs @@ -66,7 +66,7 @@ fn a_yaml_override_is_deep_merged() { "unmentioned fields keep their defaults" ); assert_eq!( - outcome.settings.nip43.and_then(|nip43| Some(nip43.enabled)), + outcome.settings.nip43.map(|nip43| nip43.enabled), Some(true) ); } diff --git a/crates/nostream-config/tests/validator.rs b/crates/nostream-config/tests/validator.rs index 7f01a5d5f..512e34db4 100644 --- a/crates/nostream-config/tests/validator.rs +++ b/crates/nostream-config/tests/validator.rs @@ -206,10 +206,6 @@ fn pow_with(overrides: &[(&str, Value)]) -> Value { document } -fn boolean(boolean: bool) -> Value { - Value::Bool(boolean) -} - fn number(number: u64) -> Value { Value::Number(number.into()) } From 2b9ce50f000e3842a9769889b88c7513004e0747 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 15:28:00 -0400 Subject: [PATCH 06/32] feat(storage): repository traits, backend resolution and the LMDB environment slice Nine repository traits mirroring src/@types/repositories.ts (2-4 representative methods each; the full surface and the 622-line event-repository body are Phase 2), event_store::resolved_backend mirroring src/utils/event-store.ts, a RedisCache stub with the cache seam crate-private, and a real LmdbEnv over heed 0.20 (open/put/get, tested against a tempdir) -- the one unsafe boundary in the workspace, which is why the workspace lint is deny with one audited allow rather than forbid. --- Cargo.lock | 525 +++++++++++++++++++- crates/nostream-storage/Cargo.toml | 21 + crates/nostream-storage/src/backend.rs | 129 +++++ crates/nostream-storage/src/cache.rs | 55 ++ crates/nostream-storage/src/error.rs | 35 ++ crates/nostream-storage/src/event_store.rs | 45 ++ crates/nostream-storage/src/lib.rs | 30 ++ crates/nostream-storage/src/repositories.rs | 346 +++++++++++++ crates/nostream-storage/tests/storage.rs | 37 ++ 9 files changed, 1220 insertions(+), 3 deletions(-) create mode 100644 crates/nostream-storage/Cargo.toml create mode 100644 crates/nostream-storage/src/backend.rs create mode 100644 crates/nostream-storage/src/cache.rs create mode 100644 crates/nostream-storage/src/error.rs create mode 100644 crates/nostream-storage/src/event_store.rs create mode 100644 crates/nostream-storage/src/lib.rs create mode 100644 crates/nostream-storage/src/repositories.rs create mode 100644 crates/nostream-storage/tests/storage.rs diff --git a/Cargo.lock b/Cargo.lock index 2ffe9ac18..5bf052d0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,11 +2,39 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] [[package]] name = "cfg-if" @@ -14,6 +42,41 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "doxygen-rs" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "415b6ec780d34dcf624666747194393603d0373b7141eef01d12ee58881507d9" +dependencies = [ + "phf", +] + [[package]] name = "equivalent" version = "1.0.3" @@ -36,6 +99,21 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -53,6 +131,148 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "heed" +version = "0.20.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d4f449bab7320c56003d37732a917e18798e2f1709d80263face2b4f9436ddb" +dependencies = [ + "bitflags", + "byteorder", + "heed-traits", + "heed-types", + "libc", + "lmdb-master-sys", + "once_cell", + "page_size", + "serde", + "synchronoise", + "url", +] + +[[package]] +name = "heed-traits" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3130048d404c57ce5a1ac61a903696e8fcde7e8c2991e9fcfc1f27c3ef74ff" + +[[package]] +name = "heed-types" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d3f528b053a6d700b2734eabcd0fd49cb8230647aa72958467527b0b7917114" +dependencies = [ + "bincode", + "byteorder", + "heed-traits", + "serde", + "serde_json", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.2" @@ -81,6 +301,23 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lmdb-master-sys" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaeb9bd22e73bd1babffff614994b341e9b2008de7bb73bf1f7e9154f1978f8b" +dependencies = [ + "cc", + "doxygen-rs", + "libc", +] + [[package]] name = "memchr" version = "2.8.3" @@ -119,18 +356,97 @@ dependencies = [ "tokio", ] +[[package]] +name = "nostream-storage" +version = "0.1.0" +dependencies = [ + "heed", + "nostream-config", + "nostream-core", + "tempfile", + "thiserror", + "tokio", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -155,6 +471,21 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rustix" version = "1.1.5" @@ -201,7 +532,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] @@ -230,6 +561,12 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -240,6 +577,18 @@ dependencies = [ "libc", ] +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + [[package]] name = "socket2" version = "0.6.5" @@ -250,6 +599,23 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.9" @@ -261,6 +627,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synchronoise" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dbc01390fc626ce8d1cffe3376ded2b72a11bb70e1c75f404a210e4daa4def2" +dependencies = [ + "crossbeam-queue", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -291,7 +677,17 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", ] [[package]] @@ -317,7 +713,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] @@ -332,12 +728,52 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" @@ -353,6 +789,89 @@ dependencies = [ "windows-link", ] +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/crates/nostream-storage/Cargo.toml b/crates/nostream-storage/Cargo.toml new file mode 100644 index 000000000..ad75bd7e2 --- /dev/null +++ b/crates/nostream-storage/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "nostream-storage" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Repository traits, backend selection and the LMDB environment slice" + +[dependencies] +nostream-config.workspace = true +nostream-core.workspace = true +heed.workspace = true +thiserror.workspace = true + +[dev-dependencies] +tempfile.workspace = true +tokio.workspace = true + +[lints] +workspace = true diff --git a/crates/nostream-storage/src/backend.rs b/crates/nostream-storage/src/backend.rs new file mode 100644 index 000000000..3baa2ba4a --- /dev/null +++ b/crates/nostream-storage/src/backend.rs @@ -0,0 +1,129 @@ +//! Backend selection and the two backends. Mirrors `src/utils/event-store.ts` (the selection +//! half) and the storage boundary in `src/@types/adapters.ts`. + +use std::path::{Path, PathBuf}; + +use heed::types::Str; + +use crate::error::StorageError; +use heed::{Database, Env, EnvOpenOptions}; + +/// The storage backends the rewrite can be configured with (`src/constants/base.ts` +/// `EventStoreBackend`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Backend { + Postgres, + Lmdb, +} + +impl Backend { + /// The settings spelling of this backend. + pub const fn as_str(self) -> &'static str { + match self { + Self::Postgres => "postgres", + Self::Lmdb => "lmdb", + } + } + + /// Parses a settings value. + pub fn parse(value: &str) -> Option { + match value { + "postgres" => Some(Self::Postgres), + "lmdb" => Some(Self::Lmdb), + _ => None, + } + } +} + +impl std::fmt::Display for Backend { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// An opened LMDB environment with one named database of string keys and values. +/// +/// The constructor is the crate's single `unsafe` boundary: LMDB requires that an environment +/// is not opened twice in the same process (heed 0.20.5's `EnvOpenOptions::open` is an +/// `unsafe fn` for exactly that reason), and `LmdbEnv` guarantees one `Env` per path. +#[derive(Debug, Clone)] +pub struct LmdbEnv { + env: Env, + path: PathBuf, +} + +/// Phase 1 stub — job 005; connects to Postgres with the Phase-2 driver choice; see `src/database/client.ts`. +#[derive(Debug, Clone, Default)] +pub struct PostgresBackend; + +impl PostgresBackend { + /// Connects to the database named by `DATABASE_URI` (or the `DB_*` parts). + /// + /// Phase 1 stub — job 005; will connect a pool through the Phase-2 driver; see `src/database/client.ts`. + pub async fn connect(_database_uri: &str) -> Result { + Err(StorageError::NotImplemented { + module: "storage::backend::postgres::connect", + }) + } +} + +impl LmdbEnv { + /// Opens (creating if needed) an LMDB environment at `path` with `map_size` bytes. + pub fn open(path: impl Into, map_size: usize) -> Result { + let path = path.into(); + std::fs::create_dir_all(&path).map_err(|source| StorageError::CreateDirectory { + path: path.clone(), + source, + })?; + + // # Safety: LMDB forbids two environments over one data directory in the same + // process; this type owns the only `Env` and is never constructed elsewhere. + #[allow(unsafe_code)] + let env = + unsafe { EnvOpenOptions::new().map_size(map_size).open(&path) }.map_err(|source| { + StorageError::OpenLmdb { + path: path.clone(), + source, + } + })?; + Ok(Self { env, path }) + } + + /// Writes one value under one key, committing immediately. + pub fn put(&self, key: &str, value: &str) -> Result<(), StorageError> { + let mut transaction = self.env.write_txn()?; + let database: Database = self.env.create_database(&mut transaction, None)?; + database.put(&mut transaction, key, value)?; + transaction.commit()?; + Ok(()) + } + + /// Reads one value; `Ok(None)` means the key is absent. + pub fn get(&self, key: &str) -> Result, StorageError> { + let transaction = self.env.read_txn()?; + let Some(database) = self.env.open_database::(&transaction, None)? else { + return Ok(None); + }; + Ok(database.get(&transaction, key)?.map(str::to_owned)) + } + + /// The environment handle, for the Phase-2 repository implementations. + pub fn env(&self) -> &Env { + &self.env + } + + /// The directory the environment was opened from. + pub fn path(&self) -> &Path { + &self.path + } +} + +/// Resolves the database path under `root` for one backend. +/// +/// Phase 1 stub — job 005; will pick the per-backend data directory; see `src/@types/adapters.ts`. +pub fn data_dir(root: &Path, backend: Backend) -> Result { + let _ = (root, backend); + Err(StorageError::NotImplemented { + module: "storage::backend::data_dir", + }) +} diff --git a/crates/nostream-storage/src/cache.rs b/crates/nostream-storage/src/cache.rs new file mode 100644 index 000000000..29a1927a0 --- /dev/null +++ b/crates/nostream-storage/src/cache.rs @@ -0,0 +1,55 @@ +//! The cache seam. Mirrors `src/@types/cache.ts` and `src/cache/client.ts`. + +use crate::error::StorageError; + +/// The cache operations the relay uses, with a default TTL for `set`. +/// +/// Crate-private in Phase 1 so the crate's public trait surface is exactly the nine +/// repositories (the cache's only consumer is inside this crate until the Redis backend +/// lands); it becomes public with the Phase-2 cache implementation. +#[allow(dead_code)] +pub(crate) trait Cache { + fn get(&self, key: &str) -> impl Future, StorageError>> + Send; + fn set(&self, key: &str, value: &str) -> impl Future> + Send; + fn del(&self, key: &str) -> impl Future> + Send; +} + +/// The Redis-backed cache (`src/cache/client.ts`). +/// +/// No `redis` dependency is carried in Phase 1; the type exists so the relay's seams are +/// visible and the Phase-2 implementation has its place marked. +#[derive(Debug, Clone, Default)] +pub struct RedisCache { + _private: (), +} + +impl RedisCache { + /// Connects to Redis at `url`. + /// + /// Phase 1 stub — job 005; will connect a redis client with the shared cache config; see `src/cache/client.ts`. + pub async fn connect(_url: &str) -> Result { + Err(StorageError::NotImplemented { + module: "storage::cache::RedisCache::connect", + }) + } +} + +impl Cache for RedisCache { + async fn get(&self, _key: &str) -> Result, StorageError> { + Err(StorageError::NotImplemented { + module: "storage::cache::RedisCache::get", + }) + } + + async fn set(&self, _key: &str, _value: &str) -> Result<(), StorageError> { + Err(StorageError::NotImplemented { + module: "storage::cache::RedisCache::set", + }) + } + + async fn del(&self, _key: &str) -> Result<(), StorageError> { + Err(StorageError::NotImplemented { + module: "storage::cache::RedisCache::del", + }) + } +} diff --git a/crates/nostream-storage/src/error.rs b/crates/nostream-storage/src/error.rs new file mode 100644 index 000000000..8b5efb8bb --- /dev/null +++ b/crates/nostream-storage/src/error.rs @@ -0,0 +1,35 @@ +//! The storage error type. + +use thiserror::Error; + +/// Errors raised by `nostream-storage`. +#[derive(Debug, Error)] +pub enum StorageError { + /// A Phase 1 stub. The module names the call that is not implemented yet. + #[error("not implemented in Phase 1 (job 005): {module}")] + NotImplemented { module: &'static str }, + + /// An LMDB environment or transaction failed. + #[error("lmdb operation failed: {0}")] + Lmdb(#[from] heed::Error), + + /// An LMDB environment could not be opened at the requested path. + #[error("failed to open the lmdb environment at {path}: {source}")] + OpenLmdb { + path: std::path::PathBuf, + #[source] + source: heed::Error, + }, + + /// A value must be valid UTF-8 to be stored through the string codecs. + #[error("lmdb values must be valid utf-8")] + NotUtf8, + + /// The environment's data directory could not be created. + #[error("failed to create {path}: {source}")] + CreateDirectory { + path: std::path::PathBuf, + #[source] + source: std::io::Error, + }, +} diff --git a/crates/nostream-storage/src/event_store.rs b/crates/nostream-storage/src/event_store.rs new file mode 100644 index 000000000..e1b4deb2e --- /dev/null +++ b/crates/nostream-storage/src/event_store.rs @@ -0,0 +1,45 @@ +//! Backend resolution. Mirrors `src/utils/event-store.ts` `getConfiguredEventStoreBackend` / +//! `getEventStoreBackend`. + +use std::env; + +use nostream_config::Settings; + +/// The environment variable the primary sets on every worker it forks +/// (`src/utils/event-store.ts` `EVENT_STORE_BACKEND_ENV`). +pub const EVENT_STORE_BACKEND_ENV: &str = "EVENT_STORE_BACKEND"; + +/// The backend the settings name, defaulting to postgres +/// (`src/utils/event-store.ts` `getConfiguredEventStoreBackend`). +pub fn configured_backend(settings: &Settings) -> nostream_core::EventStoreBackend { + settings + .event_store + .as_ref() + .map(|event_store| event_store.backend) + .unwrap_or(nostream_core::EventStoreBackend::Postgres) +} + +/// The backend a worker inherits from the primary, or the settings value +/// (`src/utils/event-store.ts` `getEventStoreBackend`). +/// +/// `is_worker` stands in for `cluster.isWorker`; the daemon owns the distinction and passes it +/// in, so this module stays a pure function of (settings, env, is_worker). +pub fn resolved_backend( + settings: &Settings, + is_worker: bool, +) -> Result { + let inherited = if is_worker { + env::var(EVENT_STORE_BACKEND_ENV).ok() + } else { + None + }; + match inherited { + None => Ok(configured_backend(settings)), + Some(name) => nostream_core::EventStoreBackend::parse(&name) + .ok_or_else(|| { + format!( + "Unsupported {EVENT_STORE_BACKEND_ENV} \"{name}\". Supported backends: postgres, lmdb" + ) + }), + } +} diff --git a/crates/nostream-storage/src/lib.rs b/crates/nostream-storage/src/lib.rs new file mode 100644 index 000000000..ab30c01ee --- /dev/null +++ b/crates/nostream-storage/src/lib.rs @@ -0,0 +1,30 @@ +//! `nostream-storage` owns the persistence seam: the repository traits, the backend selection, +//! the cache client seam and the LMDB environment. +//! +//! It owns these TypeScript paths from the capture commit +//! (`242a3001d77149e4d70151ec03060c4ef44a1547`): +//! +//! - `src/repositories/` (9 files; `src/repositories/event-repository.ts`'s 622-line body is +//! **not** ported — Phase 2 fills the implementations behind the traits) +//! - `src/database/client.ts`, `src/database/transaction.ts` +//! - `src/cache/client.ts`, `src/adapters/redis-adapter.ts` +//! - `src/utils/event-store.ts`, `src/utils/hidden-content-cache.ts` +//! - `src/@types/{adapters,cache,database,repositories,invite-code,invoice,nip05,report, +//! notification-outbox,user}.ts` +//! - `src/factories/event-repository-factory.ts` +//! +//! Phase 1 implements exactly one real slice: `backend::lmdb` opens an LMDB environment with +//! `heed` and puts/gets values, retiring the C-toolchain build risk while the crate is empty. +//! The Postgres backend and the Redis cache are labelled stubs — choosing `sqlx` versus +//! `tokio-postgres` versus a pool crate is a Phase 2 decision that would otherwise be made +//! for every repository port at once. + +pub mod backend; +pub mod cache; +pub mod error; +pub mod event_store; +pub mod repositories; + +pub use backend::{Backend, LmdbEnv}; + +pub use error::StorageError; diff --git a/crates/nostream-storage/src/repositories.rs b/crates/nostream-storage/src/repositories.rs new file mode 100644 index 000000000..e0a5ae98c --- /dev/null +++ b/crates/nostream-storage/src/repositories.rs @@ -0,0 +1,346 @@ +//! The nine repository traits. Mirrors `src/@types/repositories.ts` at the capture commit. +//! +//! Phase 1 carries each trait's representative method set (the capture's interfaces declare +//! five to eleven methods; the full surface is a Phase-2 port), and every implementation is a +//! labelled stub — the 622-line `src/repositories/event-repository.ts` body is **not** ported +//! here. Methods are async and return `Result<_, StorageError>`, which is the one part of the +//! TS boundary (`IQueryResult` extends `Promise`) that ports cleanly to tokio. + +use nostream_core::{EventId, Kind, Pubkey}; + +use crate::error::StorageError; + +/// The rows a retention pass deleted, expired and kept (`src/@types/repositories.ts` +/// `EventPurgeCounts`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct EventPurgeCounts { + pub deleted: u64, + pub expired: u64, + pub retained: u64, +} + +/// The options a retention pass takes (`src/@types/repositories.ts` `EventRetentionOptions`). +#[derive(Debug, Clone, Default)] +pub struct EventRetentionOptions { + pub max_days: Option, + pub kind_whitelist: Option>, + pub pubkey_whitelist: Option>, +} + +/// Event storage (`src/@types/repositories.ts` `IEventRepository`). +pub trait EventRepository: Send + Sync { + /// Inserts one event, returning its row id. + /// + /// Phase 1 stub — job 005; will insert a canonicalised event row; see `src/repositories/event-repository.ts`. + fn create( + &self, + _event: &nostream_core::Event, + ) -> impl Future> + Send; + + /// Counts events matching the filters. + /// + /// Phase 1 stub — job 005; will count rows through the query builder; see `src/repositories/event-repository.ts`. + fn count_by_filters( + &self, + _filters: &[nostream_core::Filter], + ) -> impl Future> + Send; + + /// Deletes events by pubkey and ids. + /// + /// Phase 1 stub — job 005; will delete NIP-09 deletions; see `src/repositories/event-repository.ts`. + fn delete_by_pubkey_and_ids( + &self, + _pubkey: &Pubkey, + _ids: &[EventId], + ) -> impl Future> + Send; + + /// Runs the retention pass. + /// + /// Phase 1 stub — job 005; will delete expired and over-retention rows; see `src/repositories/event-repository.ts`. + fn delete_expired_and_retained( + &self, + _options: &EventRetentionOptions, + ) -> impl Future> + Send; +} + +/// Invoice storage (`src/@types/repositories.ts` `IInvoiceRepository`). +pub trait InvoiceRepository: Send + Sync { + /// Loads one invoice by id. + /// + /// Phase 1 stub — job 005; will load an invoice row; see `src/repositories/invoice-repository.ts`. + fn find_by_id( + &self, + _id: &str, + ) -> impl Future, StorageError>> + Send; + + /// Upserts an invoice, returning its row id. + /// + /// Phase 1 stub — job 005; will insert or update an invoice; see `src/repositories/invoice-repository.ts`. + fn upsert(&self, _invoice: &Invoice) -> impl Future> + Send; + + /// Loads pending invoices with pagination. + /// + /// Phase 1 stub — job 005; will page pending invoices; see `src/repositories/invoice-repository.ts`. + fn find_pending( + &self, + _offset: u64, + _limit: u64, + ) -> impl Future, StorageError>> + Send; +} + +/// One payment invoice row (`src/@types/invoice.ts`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Invoice { + pub id: String, + pub pubkey: Pubkey, + pub amount: i64, + pub status: String, +} + +/// User and admission storage (`src/@types/repositories.ts` `IUserRepository`). +pub trait UserRepository: Send + Sync { + /// Loads one user by pubkey. + /// + /// Phase 1 stub — job 005; will load a user row; see `src/repositories/user-repository.ts`. + fn find_by_pubkey( + &self, + _pubkey: &Pubkey, + ) -> impl Future, StorageError>> + Send; + + /// Reads the balance of a pubkey. + /// + /// Phase 1 stub — job 005; will read the credited balance; see `src/repositories/user-repository.ts`. + fn get_balance_by_pubkey( + &self, + _pubkey: &Pubkey, + ) -> impl Future> + Send; + + /// Marks a pubkey admitted. + /// + /// Phase 1 stub — job 005; will write the admission timestamp; see `src/repositories/user-repository.ts`. + fn admit_user( + &self, + _pubkey: &Pubkey, + _admitted_at: i64, + ) -> impl Future> + Send; +} + +/// One user row (`src/@types/user.ts`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct User { + pub pubkey: Pubkey, + pub is_admitted: bool, + pub balance: i64, +} + +/// NIP-05 verification storage (`src/@types/repositories.ts` `INip05VerificationRepository`). +pub trait Nip05VerificationRepository: Send + Sync { + /// Loads the verification record for a pubkey. + /// + /// Phase 1 stub — job 005; will load the verification row; see `src/repositories/nip05-verification-repository.ts`. + fn find_by_pubkey( + &self, + _pubkey: &Pubkey, + ) -> impl Future, StorageError>> + Send; + + /// Upserts a verification record. + /// + /// Phase 1 stub — job 005; will write the verification row; see `src/repositories/nip05-verification-repository.ts`. + fn upsert( + &self, + _verification: &Nip05Verification, + ) -> impl Future> + Send; +} + +/// One NIP-05 verification row (`src/@types/nip05.ts`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Nip05Verification { + pub pubkey: Pubkey, + pub identifier: String, + pub verified_at: Option, +} + +/// Invite-code storage (`src/@types/repositories.ts` `IInviteCodeRepository`). +pub trait InviteCodeRepository: Send + Sync { + /// Creates an invite code with its mint options. + /// + /// Phase 1 stub — job 005; will persist a minted code; see `src/repositories/invite-code-repository.ts`. + fn create( + &self, + _code: &str, + _max_uses: u32, + _expiry_seconds: u64, + ) -> impl Future> + Send; + + /// Claims a code for a pubkey, returning whether the claim succeeded. + /// + /// Phase 1 stub — job 005; will atomically claim one use of a code; see `src/repositories/invite-code-repository.ts`. + fn claim_code( + &self, + _code: &str, + _pubkey: &Pubkey, + ) -> impl Future> + Send; + + /// Deletes expired codes, returning the count. + /// + /// Phase 1 stub — job 005; will delete expired codes; see `src/repositories/invite-code-repository.ts`. + fn delete_expired_codes(&self) -> impl Future> + Send; +} + +/// One invite code (`src/@types/invite-code.ts`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InviteCode { + pub code: String, + pub max_uses: u32, + pub expires_at: Option, +} + +/// DVM job storage (`src/@types/repositories.ts` `IDvmJobRepository`). +pub trait DvmJobRepository: Send + Sync { + /// Creates a job row for a NIP-90 request. + /// + /// Phase 1 stub — job 005; will persist a job request; see `src/repositories/dvm-job-repository.ts`. + fn create( + &self, + _id: &EventId, + _requester: &Pubkey, + _kind: Kind, + ) -> impl Future> + Send; + + /// Assigns a worker to a job. + /// + /// Phase 1 stub — job 005; will assign an idle worker; see `src/repositories/dvm-job-repository.ts`. + fn assign_worker( + &self, + _id: &EventId, + _worker_index: u32, + ) -> impl Future> + Send; + + /// Loads pending jobs, optionally by kind. + /// + /// Phase 1 stub — job 005; will page pending jobs; see `src/repositories/dvm-job-repository.ts`. + fn find_pending( + &self, + _limit: u64, + _kinds: &[Kind], + ) -> impl Future, StorageError>> + Send; +} + +/// One DVM job row (`src/@types/dvm.ts`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DvmJob { + pub id: EventId, + pub requester: Pubkey, + pub kind: Kind, + pub status: String, + pub worker_index: Option, +} + +/// Report storage (`src/@types/repositories.ts` `IReportRepository`). +pub trait ReportRepository: Send + Sync { + /// Creates one report row. + /// + /// Phase 1 stub — job 005; will persist a kind-1984 report; see `src/repositories/report-repository.ts`. + fn create(&self, _report: &Report) + -> impl Future> + Send; + + /// Loads the reports against one event. + /// + /// Phase 1 stub — job 005; will load reports by event id; see `src/repositories/report-repository.ts`. + fn find_by_event_id( + &self, + _event_id: &EventId, + ) -> impl Future, StorageError>> + Send; + + /// Loads actionable reports, up to `limit`. + /// + /// Phase 1 stub — job 005; will page actionable reports; see `src/repositories/report-repository.ts`. + fn find_actionable( + &self, + _limit: u64, + ) -> impl Future, StorageError>> + Send; +} + +/// One report row (`src/@types/report.ts`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Report { + pub reporter: Pubkey, + pub reported_pubkey: Option, + pub reported_event_id: Option, + pub report_type: String, + pub actionable: bool, +} + +/// Notification delivery-log storage (`src/@types/repositories.ts` +/// `INotificationDeliveryLogRepository`). +pub trait NotificationDeliveryLogRepository: Send + Sync { + /// Appends one delivery attempt. + /// + /// Phase 1 stub — job 005; will append a delivery log row; see `src/repositories/notification-delivery-log-repository.ts`. + fn append( + &self, + _entry: &DeliveryLogEntry, + ) -> impl Future> + Send; + + /// Loads the recent log, newest first. + /// + /// Phase 1 stub — job 005; will page the delivery log; see `src/repositories/notification-delivery-log-repository.ts`. + fn find_recent( + &self, + _limit: u64, + ) -> impl Future, StorageError>> + Send; + + /// Deletes entries older than the cutoff, returning the count. + /// + /// Phase 1 stub — job 005; will prune old log rows; see `src/repositories/notification-delivery-log-repository.ts`. + fn delete_older_than( + &self, + _cutoff: i64, + ) -> impl Future> + Send; +} + +/// One delivery attempt (`src/@types/operator-notifications.ts`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeliveryLogEntry { + pub outbox_id: Option, + pub event_type: String, + pub target_id: String, + pub status: String, + pub attempt_number: u32, + pub error_snippet: Option, + pub created_at: i64, +} + +/// Notification outbox storage (`src/@types/repositories.ts` `INotificationOutboxRepository`). +pub trait NotificationOutboxRepository: Send + Sync { + /// Enqueues one notification for delivery. + /// + /// Phase 1 stub — job 005; will insert an outbox message; see `src/repositories/notification-outbox-repository.ts`. + fn enqueue( + &self, + _event_type: &str, + _payload: &str, + ) -> impl Future> + Send; + + /// Claims a batch for delivery. + /// + /// Phase 1 stub — job 005; will claim unclaimed messages; see `src/repositories/notification-outbox-repository.ts`. + fn claim_batch( + &self, + _limit: u64, + ) -> impl Future, StorageError>> + Send; + + /// Marks a message delivered. + /// + /// Phase 1 stub — job 005; will mark delivery success; see `src/repositories/notification-outbox-repository.ts`. + fn mark_delivered(&self, _id: &str) -> impl Future> + Send; +} + +/// One outbox message (`src/@types/notification-outbox.ts`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OutboxMessage { + pub id: String, + pub event_type: String, + pub payload: String, + pub attempt_count: u32, +} diff --git a/crates/nostream-storage/tests/storage.rs b/crates/nostream-storage/tests/storage.rs new file mode 100644 index 000000000..ec7a06860 --- /dev/null +++ b/crates/nostream-storage/tests/storage.rs @@ -0,0 +1,37 @@ +//! The LMDB environment slice and the backend stubs. + +use std::path::Path; + +use nostream_storage::{Backend, LmdbEnv, StorageError}; + +#[tokio::test] +async fn the_lmdb_environment_round_trips_a_value() { + let directory = tempfile::tempdir().expect("tempdir"); + let env = LmdbEnv::open(directory.path(), 1024 * 1024).expect("the environment opens"); + + env.put("greeting", "hello").expect("put"); + assert_eq!(env.get("greeting").expect("get"), Some("hello".to_owned())); + assert_eq!(env.get("absent").expect("get"), None); + assert_eq!(env.path(), Path::new(directory.path())); +} + +#[test] +fn the_backend_parses_the_settings_spelling() { + assert_eq!(Backend::parse("postgres"), Some(Backend::Postgres)); + assert_eq!(Backend::parse("lmdb"), Some(Backend::Lmdb)); + assert_eq!(Backend::parse("mongodb"), None); + assert_eq!(Backend::Lmdb.to_string(), "lmdb"); +} + +#[tokio::test] +async fn the_postgres_backend_is_a_labelled_stub() { + let error = nostream_storage::backend::PostgresBackend::connect("postgres://nostream") + .await + .expect_err("the Postgres backend is a stub"); + assert!( + error + .to_string() + .contains("not implemented in Phase 1 (job 005)") + ); + assert!(matches!(error, StorageError::NotImplemented { .. })); +} From fbd82d83e6583b466a2f7ea216e1fd3e3a93b6c6 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 15:29:24 -0400 Subject: [PATCH 07/32] feat(relay): wire-handler and event-strategy skeletons Five message handlers, fifteen event-strategy modules, connection/subscription state, the two rate limiters, the broadcast fan-out and the negentropy session seam -- all labelled stubs returning RelayError::NotImplemented. No WebSocket dependency; the NIP-77 neg-* handlers are deliberately absent (they exist at the porting oracle, not at this branch's capture). --- crates/nostream-relay/Cargo.toml | 17 ++++++ crates/nostream-relay/src/broadcast.rs | 29 +++++++++ crates/nostream-relay/src/connection.rs | 60 +++++++++++++++++++ crates/nostream-relay/src/error.rs | 19 ++++++ crates/nostream-relay/src/handlers/auth.rs | 10 ++++ crates/nostream-relay/src/handlers/count.rs | 10 ++++ crates/nostream-relay/src/handlers/event.rs | 10 ++++ crates/nostream-relay/src/handlers/mod.rs | 17 ++++++ .../nostream-relay/src/handlers/subscribe.rs | 10 ++++ .../src/handlers/unsubscribe.rs | 10 ++++ crates/nostream-relay/src/lib.rs | 31 ++++++++++ crates/nostream-relay/src/limits.rs | 41 +++++++++++++ .../src/strategies/contact_list.rs | 13 ++++ .../nostream-relay/src/strategies/default.rs | 13 ++++ .../nostream-relay/src/strategies/delete.rs | 13 ++++ .../src/strategies/dvm_job_request.rs | 13 ++++ .../src/strategies/ephemeral.rs | 13 ++++ .../src/strategies/gift_wrap.rs | 13 ++++ crates/nostream-relay/src/strategies/group.rs | 13 ++++ .../src/strategies/invite_request.rs | 13 ++++ .../src/strategies/join_request.rs | 13 ++++ .../src/strategies/leave_request.rs | 13 ++++ crates/nostream-relay/src/strategies/mod.rs | 38 ++++++++++++ .../strategies/parameterized_replaceable.rs | 13 ++++ .../src/strategies/replaceable.rs | 13 ++++ .../nostream-relay/src/strategies/report.rs | 13 ++++ .../src/strategies/timestamp.rs | 13 ++++ .../nostream-relay/src/strategies/vanish.rs | 13 ++++ 28 files changed, 497 insertions(+) create mode 100644 crates/nostream-relay/Cargo.toml create mode 100644 crates/nostream-relay/src/broadcast.rs create mode 100644 crates/nostream-relay/src/connection.rs create mode 100644 crates/nostream-relay/src/error.rs create mode 100644 crates/nostream-relay/src/handlers/auth.rs create mode 100644 crates/nostream-relay/src/handlers/count.rs create mode 100644 crates/nostream-relay/src/handlers/event.rs create mode 100644 crates/nostream-relay/src/handlers/mod.rs create mode 100644 crates/nostream-relay/src/handlers/subscribe.rs create mode 100644 crates/nostream-relay/src/handlers/unsubscribe.rs create mode 100644 crates/nostream-relay/src/lib.rs create mode 100644 crates/nostream-relay/src/limits.rs create mode 100644 crates/nostream-relay/src/strategies/contact_list.rs create mode 100644 crates/nostream-relay/src/strategies/default.rs create mode 100644 crates/nostream-relay/src/strategies/delete.rs create mode 100644 crates/nostream-relay/src/strategies/dvm_job_request.rs create mode 100644 crates/nostream-relay/src/strategies/ephemeral.rs create mode 100644 crates/nostream-relay/src/strategies/gift_wrap.rs create mode 100644 crates/nostream-relay/src/strategies/group.rs create mode 100644 crates/nostream-relay/src/strategies/invite_request.rs create mode 100644 crates/nostream-relay/src/strategies/join_request.rs create mode 100644 crates/nostream-relay/src/strategies/leave_request.rs create mode 100644 crates/nostream-relay/src/strategies/mod.rs create mode 100644 crates/nostream-relay/src/strategies/parameterized_replaceable.rs create mode 100644 crates/nostream-relay/src/strategies/replaceable.rs create mode 100644 crates/nostream-relay/src/strategies/report.rs create mode 100644 crates/nostream-relay/src/strategies/timestamp.rs create mode 100644 crates/nostream-relay/src/strategies/vanish.rs diff --git a/crates/nostream-relay/Cargo.toml b/crates/nostream-relay/Cargo.toml new file mode 100644 index 000000000..146b28a20 --- /dev/null +++ b/crates/nostream-relay/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "nostream-relay" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Nostr wire handlers, event strategies, connection state and rate limiters" + +[dependencies] +nostream-config.workspace = true +nostream-core.workspace = true +nostream-storage.workspace = true +thiserror.workspace = true + +[lints] +workspace = true diff --git a/crates/nostream-relay/src/broadcast.rs b/crates/nostream-relay/src/broadcast.rs new file mode 100644 index 000000000..11b1cbd97 --- /dev/null +++ b/crates/nostream-relay/src/broadcast.rs @@ -0,0 +1,29 @@ +//! Cross-worker broadcast fan-out. Mirrors `src/relay-broadcast/redis-relay-broadcast-fanout.ts` +//! and the deduplicator/message/state utils beside it. + +use crate::error::RelayError; + +/// One relay-broadcast envelope (`src/utils/relay-broadcast-message.ts`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BroadcastMessage { + pub kind: String, + pub payload: String, +} + +/// Publishes an envelope to the other workers through Redis. +/// +/// Phase 1 stub — job 005; will publish through the Redis fan-out channel; see `src/relay-broadcast/redis-relay-broadcast-fanout.ts`. +pub async fn publish(_message: &BroadcastMessage) -> Result<(), RelayError> { + Err(RelayError::NotImplemented { + module: "relay::broadcast::publish", + }) +} + +/// Drops an envelope already seen by this worker. +/// +/// Phase 1 stub — job 005; will deduplicate by message id; see `src/utils/relay-broadcast-deduplicator.ts`. +pub fn deduplicate(_message: &BroadcastMessage) -> Result { + Err(RelayError::NotImplemented { + module: "relay::broadcast::deduplicate", + }) +} diff --git a/crates/nostream-relay/src/connection.rs b/crates/nostream-relay/src/connection.rs new file mode 100644 index 000000000..d5600239d --- /dev/null +++ b/crates/nostream-relay/src/connection.rs @@ -0,0 +1,60 @@ +//! Per-connection state. Mirrors `src/@types/{subscription,clients}.ts` and the auth/session +//! half of `src/utils/nip42-session.ts`. + +use std::collections::HashMap; + +use nostream_core::{Filter, Pubkey}; + +use crate::error::RelayError; + +/// One registered subscription (`src/@types/subscription.ts`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Subscription { + pub id: String, + pub filters: Vec, +} + +/// A connected client and what it may read (`src/@types/clients.ts`). +#[derive(Debug, Clone, Default)] +pub struct Connection { + pub pubkey: Option, + pub subscriptions: HashMap, + pub is_authenticated: bool, +} + +impl Connection { + /// Accepts a socket into the relay's connection table. + /// + /// Phase 1 stub — job 005; will register the socket, apply connection limits and send the + /// NIP-42 challenge; see `src/adapters/web-socket-adapter.ts`. + pub async fn accept(_socket: std::net::SocketAddr) -> Result { + Err(RelayError::NotImplemented { + module: "relay::connection::Connection::accept", + }) + } + + /// Registers or replaces one subscription. + /// + /// Phase 1 stub — job 005; will enforce the client subscription limits; see `src/utils/filter.ts`. + pub fn subscribe(&mut self, _subscription: Subscription) -> Result<(), RelayError> { + Err(RelayError::NotImplemented { + module: "relay::connection::Connection::subscribe", + }) + } +} + +/// One negentropy session (`src/utils/negentropy-sessions.ts`). +#[derive(Debug, Clone, Default)] +pub struct NegentropySession { + pub subscription_id: Option, + pub frame_size: u64, +} + +/// Accepts an open negentropy session for a connection. +/// +/// Phase 1 stub — job 005; will track the set-reconciliation state; see `src/utils/negentropy-sessions.ts`. +pub fn open_negentropy_session(_session: NegentropySession) -> Result<(), RelayError> { + Err(RelayError::NotImplemented { + module: "relay::connection::open_negentropy_session", + }) +} diff --git a/crates/nostream-relay/src/error.rs b/crates/nostream-relay/src/error.rs new file mode 100644 index 000000000..98891bfe1 --- /dev/null +++ b/crates/nostream-relay/src/error.rs @@ -0,0 +1,19 @@ +//! The relay's error type. + +use thiserror::Error; + +/// Errors raised by `nostream-relay`. +#[derive(Debug, Error)] +pub enum RelayError { + /// A Phase 1 stub. The module names the call that is not implemented yet. + #[error("not implemented in Phase 1 (job 005): {module}")] + NotImplemented { module: &'static str }, + + /// A client message could not be decoded or is malformed. + #[error("invalid message: {0}")] + InvalidMessage(String), + + /// The socket is closing under drain. + #[error("the relay is draining and refuses new work")] + Draining, +} diff --git a/crates/nostream-relay/src/handlers/auth.rs b/crates/nostream-relay/src/handlers/auth.rs new file mode 100644 index 000000000..78afa579d --- /dev/null +++ b/crates/nostream-relay/src/handlers/auth.rs @@ -0,0 +1,10 @@ +//! The `auth-message-handler.ts` handler. + +use crate::error::RelayError; + +/// Phase 1 stub — job 005; will handle a NIP-42 AUTH message; see `src/handlers/auth-message-handler.ts`. +pub async fn handle() -> Result<(), RelayError> { + Err(RelayError::NotImplemented { + module: "relay::handlers::auth", + }) +} diff --git a/crates/nostream-relay/src/handlers/count.rs b/crates/nostream-relay/src/handlers/count.rs new file mode 100644 index 000000000..ce880ab1c --- /dev/null +++ b/crates/nostream-relay/src/handlers/count.rs @@ -0,0 +1,10 @@ +//! The `count-message-handler.ts` handler. + +use crate::error::RelayError; + +/// Phase 1 stub — job 005; will handle a COUNT query; see `src/handlers/count-message-handler.ts`. +pub async fn handle() -> Result { + Err(RelayError::NotImplemented { + module: "relay::handlers::count", + }) +} diff --git a/crates/nostream-relay/src/handlers/event.rs b/crates/nostream-relay/src/handlers/event.rs new file mode 100644 index 000000000..2f0d948f0 --- /dev/null +++ b/crates/nostream-relay/src/handlers/event.rs @@ -0,0 +1,10 @@ +//! The `event-message-handler.ts` handler. + +use crate::error::RelayError; + +/// Phase 1 stub — job 005; will handle an EVENT message; see `src/handlers/event-message-handler.ts`. +pub async fn handle() -> Result<(), RelayError> { + Err(RelayError::NotImplemented { + module: "relay::handlers::event", + }) +} diff --git a/crates/nostream-relay/src/handlers/mod.rs b/crates/nostream-relay/src/handlers/mod.rs new file mode 100644 index 000000000..285fbe599 --- /dev/null +++ b/crates/nostream-relay/src/handlers/mod.rs @@ -0,0 +1,17 @@ +//! The five wire message handlers. Mirrors `src/handlers/*-message-handler.ts` at the capture +//! commit (the NIP-77 `neg-*` handlers are at the oracle, not here). + +pub mod auth; +pub mod count; +pub mod event; +pub mod subscribe; +pub mod unsubscribe; + +use nostream_core::Pubkey; + +/// The context one socket shares across handlers (`src/@types/message-handlers.ts`). +#[derive(Debug, Clone, Default)] +pub struct HandlerContext { + pub authenticated_pubkey: Option, + pub subscription_id: Option, +} diff --git a/crates/nostream-relay/src/handlers/subscribe.rs b/crates/nostream-relay/src/handlers/subscribe.rs new file mode 100644 index 000000000..9cb31c8bb --- /dev/null +++ b/crates/nostream-relay/src/handlers/subscribe.rs @@ -0,0 +1,10 @@ +//! The `subscribe-message-handler.ts` handler. + +use crate::error::RelayError; + +/// Phase 1 stub — job 005; will handle a REQ message; see `src/handlers/subscribe-message-handler.ts`. +pub async fn handle() -> Result<(), RelayError> { + Err(RelayError::NotImplemented { + module: "relay::handlers::subscribe", + }) +} diff --git a/crates/nostream-relay/src/handlers/unsubscribe.rs b/crates/nostream-relay/src/handlers/unsubscribe.rs new file mode 100644 index 000000000..e153f0455 --- /dev/null +++ b/crates/nostream-relay/src/handlers/unsubscribe.rs @@ -0,0 +1,10 @@ +//! The `unsubscribe-message-handler.ts` handler. + +use crate::error::RelayError; + +/// Phase 1 stub — job 005; will handle a CLOSE message; see `src/handlers/unsubscribe-message-handler.ts`. +pub async fn handle() -> Result<(), RelayError> { + Err(RelayError::NotImplemented { + module: "relay::handlers::unsubscribe", + }) +} diff --git a/crates/nostream-relay/src/lib.rs b/crates/nostream-relay/src/lib.rs new file mode 100644 index 000000000..7f22f840f --- /dev/null +++ b/crates/nostream-relay/src/lib.rs @@ -0,0 +1,31 @@ +//! `nostream-relay` owns the Nostr wire: the five message handlers, the event strategies, the +//! per-connection state, the rate limiters and the cross-worker broadcast fan-out. +//! +//! It owns these TypeScript paths from the capture commit +//! (`242a3001d77149e4d70151ec03060c4ef44a1547`): +//! +//! - `src/handlers/{auth,count,event,subscribe,unsubscribe}-message-handler.ts` +//! - `src/handlers/event-strategies/*-event-strategy.ts` (15 strategies) +//! - `src/adapters/web-socket-adapter.ts`, `src/adapters/web-socket-server-adapter.ts` +//! - `src/relay-broadcast/redis-relay-broadcast-fanout.ts` +//! - `src/controllers/admission/get-admission-check-controller.ts`, +//! `src/routes/admissions/index.ts` +//! - `src/utils/{sliding-window-rate-limiter,ewma-rate-limiter, +//! relay-broadcast-deduplicator,relay-broadcast-message,relay-broadcast-state}.ts` +//! - `src/@types/{message-handlers,subscription,clients}.ts` +//! - `src/factories/{event-strategy-factory,message-handler-factory, +//! websocket-adapter-factory,rate-limiter-factory}.ts` +//! +//! Phase 1 carries the module map and labelled stubs only: no protocol behaviour, no WebSocket +//! dependency, no transport. The NIP-77 `neg-*` handlers exist at the porting oracle +//! (`041fe34e`) and are deliberately absent here. Phase 2+ areas this crate does not own: the +//! HTTP surface (nostream-admin) and the probe family (nostream-services). + +pub mod broadcast; +pub mod connection; +pub mod error; +pub mod handlers; +pub mod limits; +pub mod strategies; + +pub use error::RelayError; diff --git a/crates/nostream-relay/src/limits.rs b/crates/nostream-relay/src/limits.rs new file mode 100644 index 000000000..408b12d95 --- /dev/null +++ b/crates/nostream-relay/src/limits.rs @@ -0,0 +1,41 @@ +//! Rate limiting. Mirrors `src/utils/sliding-window-rate-limiter.ts` and +//! `src/utils/ewma-rate-limiter.ts`. + +use crate::error::RelayError; + +/// The knobs a limiter reads from settings (`src/@types/settings.ts` `RateLimit`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RateLimitConfig { + pub period_ms: u64, + pub rate: u64, +} + +/// A fixed-window or sliding-window counter. +#[derive(Debug, Clone, Default)] +pub struct SlidingWindowRateLimiter; + +impl SlidingWindowRateLimiter { + /// Counts one event and reports whether it is under the limit. + /// + /// Phase 1 stub — job 005; will slide the window and count hits per key; see `src/utils/sliding-window-rate-limiter.ts`. + pub fn hit(&self, _key: &str, _config: &RateLimitConfig) -> Result { + Err(RelayError::NotImplemented { + module: "relay::limits::SlidingWindowRateLimiter::hit", + }) + } +} + +/// An exponentially weighted moving average limiter (`strategy: ewma`). +#[derive(Debug, Clone, Default)] +pub struct EwmaRateLimiter; + +impl EwmaRateLimiter { + /// Counts one event and reports whether it is under the limit. + /// + /// Phase 1 stub — job 005; will update the EWMA and compare against the rate; see `src/utils/ewma-rate-limiter.ts`. + pub fn hit(&self, _key: &str, _config: &RateLimitConfig) -> Result { + Err(RelayError::NotImplemented { + module: "relay::limits::EwmaRateLimiter::hit", + }) + } +} diff --git a/crates/nostream-relay/src/strategies/contact_list.rs b/crates/nostream-relay/src/strategies/contact_list.rs new file mode 100644 index 000000000..952668b95 --- /dev/null +++ b/crates/nostream-relay/src/strategies/contact_list.rs @@ -0,0 +1,13 @@ +//! The `contact-list` event strategy. Mirrors `src/handlers/event-strategies/contact-list-event-strategy.ts`. + +use nostream_core::Event; + +use crate::error::RelayError; +use crate::strategies::StrategyOutcome; + +/// Phase 1 stub — job 005; will decide acceptance for NIP-02 contact lists; see `src/handlers/event-strategies/contact-list-event-strategy.ts`. +pub async fn handle(_event: &Event) -> Result { + Err(RelayError::NotImplemented { + module: "relay::strategies::contact_list", + }) +} diff --git a/crates/nostream-relay/src/strategies/default.rs b/crates/nostream-relay/src/strategies/default.rs new file mode 100644 index 000000000..cb0dc7bda --- /dev/null +++ b/crates/nostream-relay/src/strategies/default.rs @@ -0,0 +1,13 @@ +//! The `default` event strategy. Mirrors `src/handlers/event-strategies/default-event-strategy.ts`. + +use nostream_core::Event; + +use crate::error::RelayError; +use crate::strategies::StrategyOutcome; + +/// Phase 1 stub — job 005; will decide acceptance for kinds with no dedicated strategy; see `src/handlers/event-strategies/default-event-strategy.ts`. +pub async fn handle(_event: &Event) -> Result { + Err(RelayError::NotImplemented { + module: "relay::strategies::default", + }) +} diff --git a/crates/nostream-relay/src/strategies/delete.rs b/crates/nostream-relay/src/strategies/delete.rs new file mode 100644 index 000000000..c8054602d --- /dev/null +++ b/crates/nostream-relay/src/strategies/delete.rs @@ -0,0 +1,13 @@ +//! The `delete` event strategy. Mirrors `src/handlers/event-strategies/delete-event-strategy.ts`. + +use nostream_core::Event; + +use crate::error::RelayError; +use crate::strategies::StrategyOutcome; + +/// Phase 1 stub — job 005; will decide acceptance for NIP-09 deletions; see `src/handlers/event-strategies/delete-event-strategy.ts`. +pub async fn handle(_event: &Event) -> Result { + Err(RelayError::NotImplemented { + module: "relay::strategies::delete", + }) +} diff --git a/crates/nostream-relay/src/strategies/dvm_job_request.rs b/crates/nostream-relay/src/strategies/dvm_job_request.rs new file mode 100644 index 000000000..f5ac16d1b --- /dev/null +++ b/crates/nostream-relay/src/strategies/dvm_job_request.rs @@ -0,0 +1,13 @@ +//! The `dvm-job-request` event strategy. Mirrors `src/handlers/event-strategies/dvm-job-request-event-strategy.ts`. + +use nostream_core::Event; + +use crate::error::RelayError; +use crate::strategies::StrategyOutcome; + +/// Phase 1 stub — job 005; will decide acceptance for NIP-90 job requests; see `src/handlers/event-strategies/dvm-job-request-event-strategy.ts`. +pub async fn handle(_event: &Event) -> Result { + Err(RelayError::NotImplemented { + module: "relay::strategies::dvm_job_request", + }) +} diff --git a/crates/nostream-relay/src/strategies/ephemeral.rs b/crates/nostream-relay/src/strategies/ephemeral.rs new file mode 100644 index 000000000..c7fe05eab --- /dev/null +++ b/crates/nostream-relay/src/strategies/ephemeral.rs @@ -0,0 +1,13 @@ +//! The `ephemeral` event strategy. Mirrors `src/handlers/event-strategies/ephemeral-event-strategy.ts`. + +use nostream_core::Event; + +use crate::error::RelayError; +use crate::strategies::StrategyOutcome; + +/// Phase 1 stub — job 005; will decide acceptance for kinds 20000-29999; see `src/handlers/event-strategies/ephemeral-event-strategy.ts`. +pub async fn handle(_event: &Event) -> Result { + Err(RelayError::NotImplemented { + module: "relay::strategies::ephemeral", + }) +} diff --git a/crates/nostream-relay/src/strategies/gift_wrap.rs b/crates/nostream-relay/src/strategies/gift_wrap.rs new file mode 100644 index 000000000..f8203250b --- /dev/null +++ b/crates/nostream-relay/src/strategies/gift_wrap.rs @@ -0,0 +1,13 @@ +//! The `gift-wrap` event strategy. Mirrors `src/handlers/event-strategies/gift-wrap-event-strategy.ts`. + +use nostream_core::Event; + +use crate::error::RelayError; +use crate::strategies::StrategyOutcome; + +/// Phase 1 stub — job 005; will decide acceptance for NIP-59 gift wraps; see `src/handlers/event-strategies/gift-wrap-event-strategy.ts`. +pub async fn handle(_event: &Event) -> Result { + Err(RelayError::NotImplemented { + module: "relay::strategies::gift_wrap", + }) +} diff --git a/crates/nostream-relay/src/strategies/group.rs b/crates/nostream-relay/src/strategies/group.rs new file mode 100644 index 000000000..dadd5bf56 --- /dev/null +++ b/crates/nostream-relay/src/strategies/group.rs @@ -0,0 +1,13 @@ +//! The `group` event strategy. Mirrors `src/handlers/event-strategies/group-event-strategy.ts`. + +use nostream_core::Event; + +use crate::error::RelayError; +use crate::strategies::StrategyOutcome; + +/// Phase 1 stub — job 005; will decide acceptance for Marmot group events; see `src/handlers/event-strategies/group-event-strategy.ts`. +pub async fn handle(_event: &Event) -> Result { + Err(RelayError::NotImplemented { + module: "relay::strategies::group", + }) +} diff --git a/crates/nostream-relay/src/strategies/invite_request.rs b/crates/nostream-relay/src/strategies/invite_request.rs new file mode 100644 index 000000000..518d67651 --- /dev/null +++ b/crates/nostream-relay/src/strategies/invite_request.rs @@ -0,0 +1,13 @@ +//! The `invite-request` event strategy. Mirrors `src/handlers/event-strategies/invite-request-event-strategy.ts`. + +use nostream_core::Event; + +use crate::error::RelayError; +use crate::strategies::StrategyOutcome; + +/// Phase 1 stub — job 005; will decide acceptance for NIP-43 invite requests; see `src/handlers/event-strategies/invite-request-event-strategy.ts`. +pub async fn handle(_event: &Event) -> Result { + Err(RelayError::NotImplemented { + module: "relay::strategies::invite_request", + }) +} diff --git a/crates/nostream-relay/src/strategies/join_request.rs b/crates/nostream-relay/src/strategies/join_request.rs new file mode 100644 index 000000000..9e63f04d0 --- /dev/null +++ b/crates/nostream-relay/src/strategies/join_request.rs @@ -0,0 +1,13 @@ +//! The `join-request` event strategy. Mirrors `src/handlers/event-strategies/join-request-event-strategy.ts`. + +use nostream_core::Event; + +use crate::error::RelayError; +use crate::strategies::StrategyOutcome; + +/// Phase 1 stub — job 005; will decide acceptance for kind-28934 join requests; see `src/handlers/event-strategies/join-request-event-strategy.ts`. +pub async fn handle(_event: &Event) -> Result { + Err(RelayError::NotImplemented { + module: "relay::strategies::join_request", + }) +} diff --git a/crates/nostream-relay/src/strategies/leave_request.rs b/crates/nostream-relay/src/strategies/leave_request.rs new file mode 100644 index 000000000..dada4fa2e --- /dev/null +++ b/crates/nostream-relay/src/strategies/leave_request.rs @@ -0,0 +1,13 @@ +//! The `leave-request` event strategy. Mirrors `src/handlers/event-strategies/leave-request-event-strategy.ts`. + +use nostream_core::Event; + +use crate::error::RelayError; +use crate::strategies::StrategyOutcome; + +/// Phase 1 stub — job 005; will decide acceptance for kind-28935 leave requests; see `src/handlers/event-strategies/leave-request-event-strategy.ts`. +pub async fn handle(_event: &Event) -> Result { + Err(RelayError::NotImplemented { + module: "relay::strategies::leave_request", + }) +} diff --git a/crates/nostream-relay/src/strategies/mod.rs b/crates/nostream-relay/src/strategies/mod.rs new file mode 100644 index 000000000..0162f688a --- /dev/null +++ b/crates/nostream-relay/src/strategies/mod.rs @@ -0,0 +1,38 @@ +//! The event strategies. Mirrors `src/handlers/event-strategies/*-event-strategy.ts` at the +//! capture commit: 15 strategies, one module each. + +pub mod contact_list; +pub mod default; +pub mod delete; +pub mod dvm_job_request; +pub mod ephemeral; +pub mod gift_wrap; +pub mod group; +pub mod invite_request; +pub mod join_request; +pub mod leave_request; +pub mod parameterized_replaceable; +pub mod replaceable; +pub mod report; +pub mod timestamp; +pub mod vanish; + +use nostream_core::Event; + +use crate::error::RelayError; + +/// What a strategy decided about one event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StrategyOutcome { + Accept, + Reject(String), +} + +/// Dispatches an event to its strategy. +/// +/// Phase 1 stub — job 005; will pick the strategy for the kind and run it; see `src/factories/event-strategy-factory.ts`. +pub async fn dispatch(_event: &Event) -> Result { + Err(RelayError::NotImplemented { + module: "relay::strategies::dispatch", + }) +} diff --git a/crates/nostream-relay/src/strategies/parameterized_replaceable.rs b/crates/nostream-relay/src/strategies/parameterized_replaceable.rs new file mode 100644 index 000000000..092faa7f2 --- /dev/null +++ b/crates/nostream-relay/src/strategies/parameterized_replaceable.rs @@ -0,0 +1,13 @@ +//! The `parameterized-replaceable` event strategy. Mirrors `src/handlers/event-strategies/parameterized-replaceable-event-strategy.ts`. + +use nostream_core::Event; + +use crate::error::RelayError; +use crate::strategies::StrategyOutcome; + +/// Phase 1 stub — job 005; will decide acceptance for kinds 30000-39999; see `src/handlers/event-strategies/parameterized-replaceable-event-strategy.ts`. +pub async fn handle(_event: &Event) -> Result { + Err(RelayError::NotImplemented { + module: "relay::strategies::parameterized_replaceable", + }) +} diff --git a/crates/nostream-relay/src/strategies/replaceable.rs b/crates/nostream-relay/src/strategies/replaceable.rs new file mode 100644 index 000000000..558bf82da --- /dev/null +++ b/crates/nostream-relay/src/strategies/replaceable.rs @@ -0,0 +1,13 @@ +//! The `replaceable` event strategy. Mirrors `src/handlers/event-strategies/replaceable-event-strategy.ts`. + +use nostream_core::Event; + +use crate::error::RelayError; +use crate::strategies::StrategyOutcome; + +/// Phase 1 stub — job 005; will decide acceptance for kinds 0, 3 and 10000-19999; see `src/handlers/event-strategies/replaceable-event-strategy.ts`. +pub async fn handle(_event: &Event) -> Result { + Err(RelayError::NotImplemented { + module: "relay::strategies::replaceable", + }) +} diff --git a/crates/nostream-relay/src/strategies/report.rs b/crates/nostream-relay/src/strategies/report.rs new file mode 100644 index 000000000..4babcb9e1 --- /dev/null +++ b/crates/nostream-relay/src/strategies/report.rs @@ -0,0 +1,13 @@ +//! The `report` event strategy. Mirrors `src/handlers/event-strategies/report-event-strategy.ts`. + +use nostream_core::Event; + +use crate::error::RelayError; +use crate::strategies::StrategyOutcome; + +/// Phase 1 stub — job 005; will decide acceptance for NIP-56 reports; see `src/handlers/event-strategies/report-event-strategy.ts`. +pub async fn handle(_event: &Event) -> Result { + Err(RelayError::NotImplemented { + module: "relay::strategies::report", + }) +} diff --git a/crates/nostream-relay/src/strategies/timestamp.rs b/crates/nostream-relay/src/strategies/timestamp.rs new file mode 100644 index 000000000..19d09e109 --- /dev/null +++ b/crates/nostream-relay/src/strategies/timestamp.rs @@ -0,0 +1,13 @@ +//! The `timestamp` event strategy. Mirrors `src/handlers/event-strategies/timestamp-event-strategy.ts`. + +use nostream_core::Event; + +use crate::error::RelayError; +use crate::strategies::StrategyOutcome; + +/// Phase 1 stub — job 005; will decide acceptance for NIP-03 OpenTimestamps; see `src/handlers/event-strategies/timestamp-event-strategy.ts`. +pub async fn handle(_event: &Event) -> Result { + Err(RelayError::NotImplemented { + module: "relay::strategies::timestamp", + }) +} diff --git a/crates/nostream-relay/src/strategies/vanish.rs b/crates/nostream-relay/src/strategies/vanish.rs new file mode 100644 index 000000000..3b1b5202c --- /dev/null +++ b/crates/nostream-relay/src/strategies/vanish.rs @@ -0,0 +1,13 @@ +//! The `vanish` event strategy. Mirrors `src/handlers/event-strategies/vanish-event-strategy.ts`. + +use nostream_core::Event; + +use crate::error::RelayError; +use crate::strategies::StrategyOutcome; + +/// Phase 1 stub — job 005; will decide acceptance for NIP-62 vanish requests; see `src/handlers/event-strategies/vanish-event-strategy.ts`. +pub async fn handle(_event: &Event) -> Result { + Err(RelayError::NotImplemented { + module: "relay::strategies::vanish", + }) +} From cf66de6f5ed48dea9d2f9de976c2c1d6c3b38e2e Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 15:31:00 -0400 Subject: [PATCH 08/32] feat(services): service, payment-processor and monitor skeletons The eight services, the PaymentProcessor seam over the six processor variants, the invoice/callback controllers and the NIP-66 probe family (dns, tls, nip11, ws-rtt and the run-probe merge) -- labelled stubs. No HTTP client dependency. --- crates/nostream-services/Cargo.toml | 17 ++++++ .../src/controllers/callback_controller.rs | 10 ++++ .../src/controllers/invoice_controller.rs | 10 ++++ .../nostream-services/src/controllers/mod.rs | 5 ++ crates/nostream-services/src/error.rs | 15 ++++++ crates/nostream-services/src/lib.rs | 28 ++++++++++ crates/nostream-services/src/monitor.rs | 53 +++++++++++++++++++ crates/nostream-services/src/payments.rs | 38 +++++++++++++ .../src/services/event_import_service.rs | 10 ++++ .../src/services/maintenance_service.rs | 10 ++++ crates/nostream-services/src/services/mod.rs | 10 ++++ .../src/services/nip66_event_publisher.rs | 10 ++++ .../src/services/notification_channels.rs | 10 ++++ .../services/notification_outbox_service.rs | 10 ++++ .../services/operator_notification_service.rs | 10 ++++ .../src/services/payments_service.rs | 10 ++++ .../src/services/wot_graph_service.rs | 10 ++++ 17 files changed, 266 insertions(+) create mode 100644 crates/nostream-services/Cargo.toml create mode 100644 crates/nostream-services/src/controllers/callback_controller.rs create mode 100644 crates/nostream-services/src/controllers/invoice_controller.rs create mode 100644 crates/nostream-services/src/controllers/mod.rs create mode 100644 crates/nostream-services/src/error.rs create mode 100644 crates/nostream-services/src/lib.rs create mode 100644 crates/nostream-services/src/monitor.rs create mode 100644 crates/nostream-services/src/payments.rs create mode 100644 crates/nostream-services/src/services/event_import_service.rs create mode 100644 crates/nostream-services/src/services/maintenance_service.rs create mode 100644 crates/nostream-services/src/services/mod.rs create mode 100644 crates/nostream-services/src/services/nip66_event_publisher.rs create mode 100644 crates/nostream-services/src/services/notification_channels.rs create mode 100644 crates/nostream-services/src/services/notification_outbox_service.rs create mode 100644 crates/nostream-services/src/services/operator_notification_service.rs create mode 100644 crates/nostream-services/src/services/payments_service.rs create mode 100644 crates/nostream-services/src/services/wot_graph_service.rs diff --git a/crates/nostream-services/Cargo.toml b/crates/nostream-services/Cargo.toml new file mode 100644 index 000000000..0c6e7a0a7 --- /dev/null +++ b/crates/nostream-services/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "nostream-services" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Services, payment processors and the NIP-66 monitor" + +[dependencies] +nostream-config.workspace = true +nostream-core.workspace = true +nostream-storage.workspace = true +thiserror.workspace = true + +[lints] +workspace = true diff --git a/crates/nostream-services/src/controllers/callback_controller.rs b/crates/nostream-services/src/controllers/callback_controller.rs new file mode 100644 index 000000000..841e9a5f9 --- /dev/null +++ b/crates/nostream-services/src/controllers/callback_controller.rs @@ -0,0 +1,10 @@ +//! `callbacks/index.ts`. + +use crate::error::ServicesError; + +/// Phase 1 stub — job 005; will the processor callback routes; see `src/controllers/callbacks/index.ts`. +pub async fn handle() -> Result<(), ServicesError> { + Err(ServicesError::NotImplemented { + module: "services::controllers::callback_controller", + }) +} diff --git a/crates/nostream-services/src/controllers/invoice_controller.rs b/crates/nostream-services/src/controllers/invoice_controller.rs new file mode 100644 index 000000000..0571badaf --- /dev/null +++ b/crates/nostream-services/src/controllers/invoice_controller.rs @@ -0,0 +1,10 @@ +//! `invoices/index.ts`. + +use crate::error::ServicesError; + +/// Phase 1 stub — job 005; will the invoice callback route; see `src/controllers/invoices/index.ts`. +pub async fn handle() -> Result<(), ServicesError> { + Err(ServicesError::NotImplemented { + module: "services::controllers::invoice_controller", + }) +} diff --git a/crates/nostream-services/src/controllers/mod.rs b/crates/nostream-services/src/controllers/mod.rs new file mode 100644 index 000000000..5c998e5e9 --- /dev/null +++ b/crates/nostream-services/src/controllers/mod.rs @@ -0,0 +1,5 @@ +//! The invoice and callback controller skeletons. Mirrors `src/controllers/{invoices,callbacks}/` +//! and `src/routes/{invoices,callbacks}/index.ts`. + +pub mod callback_controller; +pub mod invoice_controller; diff --git a/crates/nostream-services/src/error.rs b/crates/nostream-services/src/error.rs new file mode 100644 index 000000000..a60d0b296 --- /dev/null +++ b/crates/nostream-services/src/error.rs @@ -0,0 +1,15 @@ +//! The services' error type. + +use thiserror::Error; + +/// Errors raised by `nostream-services`. +#[derive(Debug, Error)] +pub enum ServicesError { + /// A Phase 1 stub. The module names the call that is not implemented yet. + #[error("not implemented in Phase 1 (job 005): {module}")] + NotImplemented { module: &'static str }, + + /// A payment processor refused a request. + #[error("payment processor error: {0}")] + Payment(String), +} diff --git a/crates/nostream-services/src/lib.rs b/crates/nostream-services/src/lib.rs new file mode 100644 index 000000000..a442297cd --- /dev/null +++ b/crates/nostream-services/src/lib.rs @@ -0,0 +1,28 @@ +//! `nostream-services` owns the services, the payment processors, the invoice/callback +//! controllers and the NIP-66 monitor. +//! +//! It owns these TypeScript paths from the capture commit +//! (`242a3001d77149e4d70151ec03060c4ef44a1547`): +//! +//! - `src/services/` (8 files) +//! - `src/payments-processors/` (7 files) +//! - `src/controllers/{callbacks,invoices}/` (7) and `src/routes/{callbacks,invoices}/index.ts` +//! - `src/schemas/{lnbits,nodeless,opennode,zebedee}-callback-schema.ts` +//! - the NIP-66 monitor: `src/utils/relay-probe/**` (10 files), +//! `src/utils/{relay-probe-snapshot,relay-probe-targets,monitor-identity, +//! network-health-mismatches,invoice,operator-notification-enqueue}.ts` +//! - `src/@types/{services,dvm,operator-notifications}.ts` +//! - `src/factories/{payments-processors/*,maintenance-service-factory, +//! notification-outbox-service-factory,payments-service-factory,wot-graph-service-factory}.ts` +//! +//! Phase 1 carries the module map and labelled stubs only: no HTTP client dependency, no probe +//! run. Phase 2+ areas this crate does not own: the relay wire (nostream-relay) and the admin +//! API (nostream-admin). + +pub mod controllers; +pub mod error; +pub mod monitor; +pub mod payments; +pub mod services; + +pub use error::ServicesError; diff --git a/crates/nostream-services/src/monitor.rs b/crates/nostream-services/src/monitor.rs new file mode 100644 index 000000000..1965ec581 --- /dev/null +++ b/crates/nostream-services/src/monitor.rs @@ -0,0 +1,53 @@ +//! The NIP-66 probe family. Mirrors `src/utils/relay-probe/**`, `relay-probe-snapshot.ts`, +//! `relay-probe-targets.ts` and `monitor-identity.ts`. + +use crate::error::ServicesError; + +/// One probe target (`src/utils/relay-probe/target.ts`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Target { + pub relay_url: String, + pub network: Option, +} + +/// The result of one probe run (`src/utils/relay-probe-snapshot.ts`). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Snapshot { + pub reachable: bool, + pub rtt_ms: Option, +} + +/// Phase 1 stub — job 005; will resolve a DNS record with the cache TTL; see `src/utils/relay-probe/dns-probe.ts`. +pub async fn dns_probe(_target: &Target) -> Result { + Err(ServicesError::NotImplemented { + module: "services::monitor::DnsProbe::execute", + }) +} + +/// Phase 1 stub — job 005; will open TLS and record the handshake time; see `src/utils/relay-probe/tls-probe.ts`. +pub async fn tls_probe(_target: &Target) -> Result { + Err(ServicesError::NotImplemented { + module: "services::monitor::TlsProbe::execute", + }) +} + +/// Phase 1 stub — job 005; will fetch the NIP-11 document; see `src/utils/relay-probe/nip11-probe.ts`. +pub async fn nip11_probe(_target: &Target) -> Result { + Err(ServicesError::NotImplemented { + module: "services::monitor::Nip11Probe::execute", + }) +} + +/// Phase 1 stub — job 005; will speak the WebSocket protocol and read the RTT; see `src/utils/relay-probe/ws-rtt-probe.ts`. +pub async fn ws_rtt_probe(_target: &Target) -> Result { + Err(ServicesError::NotImplemented { + module: "services::monitor::WsRttProbe::execute", + }) +} + +/// Phase 1 stub — job 005; will run every probe for one target and merge the snapshot; see `src/utils/relay-probe/run-probe.ts`. +pub async fn run_probe(_target: &Target) -> Result { + Err(ServicesError::NotImplemented { + module: "services::monitor::RunProbe::execute", + }) +} diff --git a/crates/nostream-services/src/payments.rs b/crates/nostream-services/src/payments.rs new file mode 100644 index 000000000..5e7718a7a --- /dev/null +++ b/crates/nostream-services/src/payments.rs @@ -0,0 +1,38 @@ +//! The payment-processor seam. Mirrors `src/payments-processors/*-payment(s)-processor.ts` +//! and `src/@types/settings.ts` `keyof PaymentsProcessors`. + +use nostream_config::settings::Processor; + +use crate::error::ServicesError; + +/// One invoice as the processors see it (`src/@types/invoice.ts` shape). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProcessorInvoice { + pub id: String, + pub amount_msats: i64, + pub description: String, + pub invoice: String, + pub expiry_seconds: Option, +} + +/// What every processor implements, one variant per `keyof PaymentsProcessors`. +pub trait PaymentProcessor: Send + Sync + 'static { + /// The processor this implementation serves. + fn processor(&self) -> Processor; + + /// Creates an invoice for an admission/publication fee. + /// + /// Phase 1 stub — job 005; will create an invoice through the processor's API; see `src/payments-processors/`. + fn create_invoice( + &self, + amount_msats: i64, + description: &str, + ) -> std::pin::Pin> + Send>>; +} + +/// Phase 1 stub — job 005; will resolve the processor named by settings; see `src/factories/payments-processors/`. +pub fn resolve(_processor: Processor) -> Result, ServicesError> { + Err(ServicesError::NotImplemented { + module: "services::payments::resolve", + }) +} diff --git a/crates/nostream-services/src/services/event_import_service.rs b/crates/nostream-services/src/services/event_import_service.rs new file mode 100644 index 000000000..74fa71d4e --- /dev/null +++ b/crates/nostream-services/src/services/event_import_service.rs @@ -0,0 +1,10 @@ +//! `event-import-service.ts`. + +use crate::error::ServicesError; + +/// Phase 1 stub — job 005; will replays exported events into the store; see `src/services/event-import-service.ts`. +pub async fn run() -> Result<(), ServicesError> { + Err(ServicesError::NotImplemented { + module: "services::services::event_import_service", + }) +} diff --git a/crates/nostream-services/src/services/maintenance_service.rs b/crates/nostream-services/src/services/maintenance_service.rs new file mode 100644 index 000000000..1e14a8e01 --- /dev/null +++ b/crates/nostream-services/src/services/maintenance_service.rs @@ -0,0 +1,10 @@ +//! `maintenance-service.ts`. + +use crate::error::ServicesError; + +/// Phase 1 stub — job 005; will runs the retention and cleanup passes; see `src/services/maintenance-service.ts`. +pub async fn run() -> Result<(), ServicesError> { + Err(ServicesError::NotImplemented { + module: "services::services::maintenance_service", + }) +} diff --git a/crates/nostream-services/src/services/mod.rs b/crates/nostream-services/src/services/mod.rs new file mode 100644 index 000000000..838b6f6f8 --- /dev/null +++ b/crates/nostream-services/src/services/mod.rs @@ -0,0 +1,10 @@ +//! The eight services. Mirrors `src/services/*.ts` at the capture commit. + +pub mod event_import_service; +pub mod maintenance_service; +pub mod nip66_event_publisher; +pub mod notification_channels; +pub mod notification_outbox_service; +pub mod operator_notification_service; +pub mod payments_service; +pub mod wot_graph_service; diff --git a/crates/nostream-services/src/services/nip66_event_publisher.rs b/crates/nostream-services/src/services/nip66_event_publisher.rs new file mode 100644 index 000000000..8bd4a6ecb --- /dev/null +++ b/crates/nostream-services/src/services/nip66_event_publisher.rs @@ -0,0 +1,10 @@ +//! `nip66-event-publisher.ts`. + +use crate::error::ServicesError; + +/// Phase 1 stub — job 005; will publishes kind-30166 discovery events; see `src/services/nip66-event-publisher.ts`. +pub async fn run() -> Result<(), ServicesError> { + Err(ServicesError::NotImplemented { + module: "services::services::nip66_event_publisher", + }) +} diff --git a/crates/nostream-services/src/services/notification_channels.rs b/crates/nostream-services/src/services/notification_channels.rs new file mode 100644 index 000000000..bbbd3adf2 --- /dev/null +++ b/crates/nostream-services/src/services/notification_channels.rs @@ -0,0 +1,10 @@ +//! `notification-channels.ts`. + +use crate::error::ServicesError; + +/// Phase 1 stub — job 005; will delivers notifications over the four channels; see `src/services/notification-channels.ts`. +pub async fn run() -> Result<(), ServicesError> { + Err(ServicesError::NotImplemented { + module: "services::services::notification_channels", + }) +} diff --git a/crates/nostream-services/src/services/notification_outbox_service.rs b/crates/nostream-services/src/services/notification_outbox_service.rs new file mode 100644 index 000000000..ea631a5b5 --- /dev/null +++ b/crates/nostream-services/src/services/notification_outbox_service.rs @@ -0,0 +1,10 @@ +//! `notification-outbox-service.ts`. + +use crate::error::ServicesError; + +/// Phase 1 stub — job 005; will drives the notification outbox; see `src/services/notification-outbox-service.ts`. +pub async fn run() -> Result<(), ServicesError> { + Err(ServicesError::NotImplemented { + module: "services::services::notification_outbox_service", + }) +} diff --git a/crates/nostream-services/src/services/operator_notification_service.rs b/crates/nostream-services/src/services/operator_notification_service.rs new file mode 100644 index 000000000..42e9ce154 --- /dev/null +++ b/crates/nostream-services/src/services/operator_notification_service.rs @@ -0,0 +1,10 @@ +//! `operator-notification-service.ts`. + +use crate::error::ServicesError; + +/// Phase 1 stub — job 005; will enqueues operator notifications; see `src/services/operator-notification-service.ts`. +pub async fn run() -> Result<(), ServicesError> { + Err(ServicesError::NotImplemented { + module: "services::services::operator_notification_service", + }) +} diff --git a/crates/nostream-services/src/services/payments_service.rs b/crates/nostream-services/src/services/payments_service.rs new file mode 100644 index 000000000..5b8968b75 --- /dev/null +++ b/crates/nostream-services/src/services/payments_service.rs @@ -0,0 +1,10 @@ +//! `payments-service.ts`. + +use crate::error::ServicesError; + +/// Phase 1 stub — job 005; will settles admission and publication fees; see `src/services/payments-service.ts`. +pub async fn run() -> Result<(), ServicesError> { + Err(ServicesError::NotImplemented { + module: "services::services::payments_service", + }) +} diff --git a/crates/nostream-services/src/services/wot_graph_service.rs b/crates/nostream-services/src/services/wot_graph_service.rs new file mode 100644 index 000000000..fa5a3b10c --- /dev/null +++ b/crates/nostream-services/src/services/wot_graph_service.rs @@ -0,0 +1,10 @@ +//! `wot-graph-service.ts`. + +use crate::error::ServicesError; + +/// Phase 1 stub — job 005; will maintains the web-of-trust graph; see `src/services/wot-graph-service.ts`. +pub async fn run() -> Result<(), ServicesError> { + Err(ServicesError::NotImplemented { + module: "services::services::wot_graph_service", + }) +} From 13ab185e8c6c4cc97c9ff5b097d8548095933a8b Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 15:31:40 -0400 Subject: [PATCH 09/32] feat(admin): controller, route and middleware skeletons The admin controllers and request handlers as typed skeletons, the route table, the password auth provider and the admin utils. No axum in Phase 1; the MCP server has no TS counterpart at the capture and is named Phase 5. --- crates/nostream-admin/Cargo.toml | 17 +++++++++ crates/nostream-admin/src/auth.rs | 28 ++++++++++++++ .../src/controllers/admin_auth_middleware.rs | 10 +++++ .../controllers/admin_json_body_middleware.rs | 10 +++++ .../admin_rate_limit_middleware.rs | 10 +++++ .../src/controllers/get_dashboard.rs | 10 +++++ .../src/controllers/get_health.rs | 10 +++++ .../src/controllers/get_privacy.rs | 10 +++++ .../src/controllers/get_readyz.rs | 10 +++++ .../src/controllers/get_settings.rs | 10 +++++ .../src/controllers/get_terms.rs | 10 +++++ crates/nostream-admin/src/controllers/mod.rs | 17 +++++++++ .../src/controllers/nodeinfo.rs | 10 +++++ .../src/controllers/patch_settings.rs | 10 +++++ .../src/controllers/post_login.rs | 10 +++++ .../src/controllers/post_settings_validate.rs | 10 +++++ crates/nostream-admin/src/controllers/root.rs | 10 +++++ crates/nostream-admin/src/error.rs | 19 ++++++++++ crates/nostream-admin/src/lib.rs | 26 +++++++++++++ crates/nostream-admin/src/routes/mod.rs | 37 +++++++++++++++++++ crates/nostream-admin/src/utils/grafana.rs | 10 +++++ crates/nostream-admin/src/utils/health.rs | 10 +++++ crates/nostream-admin/src/utils/metrics.rs | 10 +++++ crates/nostream-admin/src/utils/mod.rs | 10 +++++ crates/nostream-admin/src/utils/password.rs | 10 +++++ crates/nostream-admin/src/utils/rate_limit.rs | 10 +++++ crates/nostream-admin/src/utils/session.rs | 10 +++++ 27 files changed, 354 insertions(+) create mode 100644 crates/nostream-admin/Cargo.toml create mode 100644 crates/nostream-admin/src/auth.rs create mode 100644 crates/nostream-admin/src/controllers/admin_auth_middleware.rs create mode 100644 crates/nostream-admin/src/controllers/admin_json_body_middleware.rs create mode 100644 crates/nostream-admin/src/controllers/admin_rate_limit_middleware.rs create mode 100644 crates/nostream-admin/src/controllers/get_dashboard.rs create mode 100644 crates/nostream-admin/src/controllers/get_health.rs create mode 100644 crates/nostream-admin/src/controllers/get_privacy.rs create mode 100644 crates/nostream-admin/src/controllers/get_readyz.rs create mode 100644 crates/nostream-admin/src/controllers/get_settings.rs create mode 100644 crates/nostream-admin/src/controllers/get_terms.rs create mode 100644 crates/nostream-admin/src/controllers/mod.rs create mode 100644 crates/nostream-admin/src/controllers/nodeinfo.rs create mode 100644 crates/nostream-admin/src/controllers/patch_settings.rs create mode 100644 crates/nostream-admin/src/controllers/post_login.rs create mode 100644 crates/nostream-admin/src/controllers/post_settings_validate.rs create mode 100644 crates/nostream-admin/src/controllers/root.rs create mode 100644 crates/nostream-admin/src/error.rs create mode 100644 crates/nostream-admin/src/lib.rs create mode 100644 crates/nostream-admin/src/routes/mod.rs create mode 100644 crates/nostream-admin/src/utils/grafana.rs create mode 100644 crates/nostream-admin/src/utils/health.rs create mode 100644 crates/nostream-admin/src/utils/metrics.rs create mode 100644 crates/nostream-admin/src/utils/mod.rs create mode 100644 crates/nostream-admin/src/utils/password.rs create mode 100644 crates/nostream-admin/src/utils/rate_limit.rs create mode 100644 crates/nostream-admin/src/utils/session.rs diff --git a/crates/nostream-admin/Cargo.toml b/crates/nostream-admin/Cargo.toml new file mode 100644 index 000000000..85787fb91 --- /dev/null +++ b/crates/nostream-admin/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "nostream-admin" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Admin controllers, routes, middleware and authentication" + +[dependencies] +nostream-config.workspace = true +nostream-core.workspace = true +nostream-storage.workspace = true +thiserror.workspace = true + +[lints] +workspace = true diff --git a/crates/nostream-admin/src/auth.rs b/crates/nostream-admin/src/auth.rs new file mode 100644 index 000000000..ca927c9f2 --- /dev/null +++ b/crates/nostream-admin/src/auth.rs @@ -0,0 +1,28 @@ +//! Admin authentication. Mirrors `src/admin/password-admin-auth-provider.ts` and +//! `src/utils/admin-{password,session,rate-limit}.ts`. + +use crate::error::AdminError; + +/// The authentication provider the admin routes accept. +#[derive(Debug, Clone, Default)] +pub struct PasswordAuthProvider; + +impl PasswordAuthProvider { + /// Verifies a password against the configured hash. + /// + /// Phase 1 stub — job 005; will verify with argon2 and mint a session; see `src/admin/password-admin-auth-provider.ts`. + pub async fn verify(_password: &str, _hash: &str) -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::auth::PasswordAuthProvider::verify", + }) + } +} + +/// Issues a session token with the configured TTL. +/// +/// Phase 1 stub — job 005; will mint and store a session; see `src/utils/admin-session.ts`. +pub fn issue_session(_ttl_seconds: u64) -> Result { + Err(AdminError::NotImplemented { + module: "admin::auth::issue_session", + }) +} diff --git a/crates/nostream-admin/src/controllers/admin_auth_middleware.rs b/crates/nostream-admin/src/controllers/admin_auth_middleware.rs new file mode 100644 index 000000000..3b8be1fd6 --- /dev/null +++ b/crates/nostream-admin/src/controllers/admin_auth_middleware.rs @@ -0,0 +1,10 @@ +//! `admin-auth-middleware.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will authenticates admin requests; see `src/controllers/admin/admin-auth-middleware.ts` or `src/handlers/request-handlers/admin-auth-middleware.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::admin_auth_middleware", + }) +} diff --git a/crates/nostream-admin/src/controllers/admin_json_body_middleware.rs b/crates/nostream-admin/src/controllers/admin_json_body_middleware.rs new file mode 100644 index 000000000..4bc08354b --- /dev/null +++ b/crates/nostream-admin/src/controllers/admin_json_body_middleware.rs @@ -0,0 +1,10 @@ +//! `admin-json-body-middleware.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will parses and validates the JSON body; see `src/controllers/admin/admin-json-body-middleware.ts` or `src/handlers/request-handlers/admin-json-body-middleware.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::admin_json_body_middleware", + }) +} diff --git a/crates/nostream-admin/src/controllers/admin_rate_limit_middleware.rs b/crates/nostream-admin/src/controllers/admin_rate_limit_middleware.rs new file mode 100644 index 000000000..adf6838e9 --- /dev/null +++ b/crates/nostream-admin/src/controllers/admin_rate_limit_middleware.rs @@ -0,0 +1,10 @@ +//! `admin-rate-limit-middleware.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will rate-limits admin requests; see `src/controllers/admin/admin-rate-limit-middleware.ts` or `src/handlers/request-handlers/admin-rate-limit-middleware.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::admin_rate_limit_middleware", + }) +} diff --git a/crates/nostream-admin/src/controllers/get_dashboard.rs b/crates/nostream-admin/src/controllers/get_dashboard.rs new file mode 100644 index 000000000..1aa3e3813 --- /dev/null +++ b/crates/nostream-admin/src/controllers/get_dashboard.rs @@ -0,0 +1,10 @@ +//! `get-admin-dashboard-controller.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will renders the admin dashboard payload; see `src/controllers/admin/get-admin-dashboard-controller.ts` or `src/handlers/request-handlers/get-admin-dashboard-controller.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::get_dashboard", + }) +} diff --git a/crates/nostream-admin/src/controllers/get_health.rs b/crates/nostream-admin/src/controllers/get_health.rs new file mode 100644 index 000000000..de69491cc --- /dev/null +++ b/crates/nostream-admin/src/controllers/get_health.rs @@ -0,0 +1,10 @@ +//! `get-health-request-handler.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will reports the relay health payload; see `src/controllers/admin/get-health-request-handler.ts` or `src/handlers/request-handlers/get-health-request-handler.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::get_health", + }) +} diff --git a/crates/nostream-admin/src/controllers/get_privacy.rs b/crates/nostream-admin/src/controllers/get_privacy.rs new file mode 100644 index 000000000..d587410b9 --- /dev/null +++ b/crates/nostream-admin/src/controllers/get_privacy.rs @@ -0,0 +1,10 @@ +//! `get-privacy-request-handler.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will serves the privacy document; see `src/controllers/admin/get-privacy-request-handler.ts` or `src/handlers/request-handlers/get-privacy-request-handler.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::get_privacy", + }) +} diff --git a/crates/nostream-admin/src/controllers/get_readyz.rs b/crates/nostream-admin/src/controllers/get_readyz.rs new file mode 100644 index 000000000..7557d0510 --- /dev/null +++ b/crates/nostream-admin/src/controllers/get_readyz.rs @@ -0,0 +1,10 @@ +//! `get-readyz-request-handler.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will reports readiness; see `src/controllers/admin/get-readyz-request-handler.ts` or `src/handlers/request-handlers/get-readyz-request-handler.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::get_readyz", + }) +} diff --git a/crates/nostream-admin/src/controllers/get_settings.rs b/crates/nostream-admin/src/controllers/get_settings.rs new file mode 100644 index 000000000..3ce498da5 --- /dev/null +++ b/crates/nostream-admin/src/controllers/get_settings.rs @@ -0,0 +1,10 @@ +//! `get-settings-controller.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will reads the merged, redacted settings; see `src/controllers/admin/get-settings-controller.ts` or `src/handlers/request-handlers/get-settings-controller.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::get_settings", + }) +} diff --git a/crates/nostream-admin/src/controllers/get_terms.rs b/crates/nostream-admin/src/controllers/get_terms.rs new file mode 100644 index 000000000..28ee92c56 --- /dev/null +++ b/crates/nostream-admin/src/controllers/get_terms.rs @@ -0,0 +1,10 @@ +//! `get-terms-request-handler.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will serves the terms document; see `src/controllers/admin/get-terms-request-handler.ts` or `src/handlers/request-handlers/get-terms-request-handler.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::get_terms", + }) +} diff --git a/crates/nostream-admin/src/controllers/mod.rs b/crates/nostream-admin/src/controllers/mod.rs new file mode 100644 index 000000000..54684bfee --- /dev/null +++ b/crates/nostream-admin/src/controllers/mod.rs @@ -0,0 +1,17 @@ +//! The admin controllers and request handlers. Mirrors `src/controllers/admin/` and +//! `src/handlers/request-handlers/` at the capture commit. + +pub mod admin_auth_middleware; +pub mod admin_json_body_middleware; +pub mod admin_rate_limit_middleware; +pub mod get_dashboard; +pub mod get_health; +pub mod get_privacy; +pub mod get_readyz; +pub mod get_settings; +pub mod get_terms; +pub mod nodeinfo; +pub mod patch_settings; +pub mod post_login; +pub mod post_settings_validate; +pub mod root; diff --git a/crates/nostream-admin/src/controllers/nodeinfo.rs b/crates/nostream-admin/src/controllers/nodeinfo.rs new file mode 100644 index 000000000..3c787a0b4 --- /dev/null +++ b/crates/nostream-admin/src/controllers/nodeinfo.rs @@ -0,0 +1,10 @@ +//! `nodeinfo-handler.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will serves the nodeinfo document; see `src/controllers/admin/nodeinfo-handler.ts` or `src/handlers/request-handlers/nodeinfo-handler.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::nodeinfo", + }) +} diff --git a/crates/nostream-admin/src/controllers/patch_settings.rs b/crates/nostream-admin/src/controllers/patch_settings.rs new file mode 100644 index 000000000..de2a5a7f2 --- /dev/null +++ b/crates/nostream-admin/src/controllers/patch_settings.rs @@ -0,0 +1,10 @@ +//! `patch-settings-controller.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will applies a settings patch; see `src/controllers/admin/patch-settings-controller.ts` or `src/handlers/request-handlers/patch-settings-controller.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::patch_settings", + }) +} diff --git a/crates/nostream-admin/src/controllers/post_login.rs b/crates/nostream-admin/src/controllers/post_login.rs new file mode 100644 index 000000000..d38e663f9 --- /dev/null +++ b/crates/nostream-admin/src/controllers/post_login.rs @@ -0,0 +1,10 @@ +//! `post-login-controller.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will issues an admin session; see `src/controllers/admin/post-login-controller.ts` or `src/handlers/request-handlers/post-login-controller.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::post_login", + }) +} diff --git a/crates/nostream-admin/src/controllers/post_settings_validate.rs b/crates/nostream-admin/src/controllers/post_settings_validate.rs new file mode 100644 index 000000000..4b119aef5 --- /dev/null +++ b/crates/nostream-admin/src/controllers/post_settings_validate.rs @@ -0,0 +1,10 @@ +//! `post-settings-validate-controller.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will validates a patch and returns the issues; see `src/controllers/admin/post-settings-validate-controller.ts` or `src/handlers/request-handlers/post-settings-validate-controller.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::post_settings_validate", + }) +} diff --git a/crates/nostream-admin/src/controllers/root.rs b/crates/nostream-admin/src/controllers/root.rs new file mode 100644 index 000000000..ef46021a2 --- /dev/null +++ b/crates/nostream-admin/src/controllers/root.rs @@ -0,0 +1,10 @@ +//! `root-request-handler.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will serves the relay root document; see `src/controllers/admin/root-request-handler.ts` or `src/handlers/request-handlers/root-request-handler.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::root", + }) +} diff --git a/crates/nostream-admin/src/error.rs b/crates/nostream-admin/src/error.rs new file mode 100644 index 000000000..48bdc5917 --- /dev/null +++ b/crates/nostream-admin/src/error.rs @@ -0,0 +1,19 @@ +//! The admin error type. + +use thiserror::Error; + +/// Errors raised by `nostream-admin`. +#[derive(Debug, Error)] +pub enum AdminError { + /// A Phase 1 stub. The module names the call that is not implemented yet. + #[error("not implemented in Phase 1 (job 005): {module}")] + NotImplemented { module: &'static str }, + + /// A request failed authentication. + #[error("admin request is unauthorised: {0}")] + Unauthorised(String), + + /// A request body is not valid. + #[error("admin request is invalid: {0}")] + Invalid(String), +} diff --git a/crates/nostream-admin/src/lib.rs b/crates/nostream-admin/src/lib.rs new file mode 100644 index 000000000..59ff73779 --- /dev/null +++ b/crates/nostream-admin/src/lib.rs @@ -0,0 +1,26 @@ +//! `nostream-admin` owns the admin HTTP surface: the sixteen admin controllers, the route +//! table, the request handlers, the authentication and the admin utils. +//! +//! It owns these TypeScript paths from the capture commit +//! (`242a3001d77149e4d70151ec03060c4ef44a1547`): +//! +//! - `src/handlers/request-handlers/` (14 files, HTTP rather than Nostr wire) +//! - `src/controllers/admin/` (16) and `src/routes/admin/index.ts` +//! - `src/schemas/admin-*.ts` (3) +//! - `src/utils/admin-*.ts` (7 files, except `admin-notifications-settings.ts`, which belongs +//! to `nostream-config`) +//! - `src/admin/password-admin-auth-provider.ts` +//! - `src/@types/{admin,controllers}.ts` +//! - `src/factories/controllers/*` and `src/factories/admin-auth-provider-factory.ts` +//! +//! The MCP server has **no TypeScript counterpart** at the capture (`grep -rn -i mcp src/` is +//! empty); it is named here as Phase 5 and has no module. No `axum` in Phase 1: handlers are +//! typed skeletons, not mounted. + +pub mod auth; +pub mod controllers; +pub mod error; +pub mod routes; +pub mod utils; + +pub use error::AdminError; diff --git a/crates/nostream-admin/src/routes/mod.rs b/crates/nostream-admin/src/routes/mod.rs new file mode 100644 index 000000000..5cdfb9dd0 --- /dev/null +++ b/crates/nostream-admin/src/routes/mod.rs @@ -0,0 +1,37 @@ +//! The admin route table. Mirrors `src/routes/admin/index.ts`. +//! +//! No `axum` in Phase 1: the routes are a typed table, not mounted handlers. + +use crate::error::AdminError; + +/// One route of the admin API. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AdminRoute { + pub method: &'static str, + pub path: &'static str, +} + +/// The table, in the order the capture registers them. +pub const ADMIN_ROUTES: [AdminRoute; 3] = [ + AdminRoute { + method: "POST", + path: "/admin/login", + }, + AdminRoute { + method: "GET", + path: "/admin/settings", + }, + AdminRoute { + method: "PATCH", + path: "/admin/settings", + }, +]; + +/// Mounts the table onto a Phase-2 HTTP router. +/// +/// Phase 1 stub — job 005; will mount the routes; see `src/routes/admin/index.ts`. +pub fn mount() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::routes::mount", + }) +} diff --git a/crates/nostream-admin/src/utils/grafana.rs b/crates/nostream-admin/src/utils/grafana.rs new file mode 100644 index 000000000..8c354c26c --- /dev/null +++ b/crates/nostream-admin/src/utils/grafana.rs @@ -0,0 +1,10 @@ +//! `admin-grafana.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will renders dashboard links; see `src/utils/admin-grafana.ts`. +pub fn run() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::utils::grafana", + }) +} diff --git a/crates/nostream-admin/src/utils/health.rs b/crates/nostream-admin/src/utils/health.rs new file mode 100644 index 000000000..58bdc53f2 --- /dev/null +++ b/crates/nostream-admin/src/utils/health.rs @@ -0,0 +1,10 @@ +//! `admin-health.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will computes the health payload; see `src/utils/admin-health.ts`. +pub fn run() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::utils::health", + }) +} diff --git a/crates/nostream-admin/src/utils/metrics.rs b/crates/nostream-admin/src/utils/metrics.rs new file mode 100644 index 000000000..38cef33b1 --- /dev/null +++ b/crates/nostream-admin/src/utils/metrics.rs @@ -0,0 +1,10 @@ +//! `admin-metrics.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will exposes the admin metrics; see `src/utils/admin-metrics.ts`. +pub fn run() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::utils::metrics", + }) +} diff --git a/crates/nostream-admin/src/utils/mod.rs b/crates/nostream-admin/src/utils/mod.rs new file mode 100644 index 000000000..1c99119e3 --- /dev/null +++ b/crates/nostream-admin/src/utils/mod.rs @@ -0,0 +1,10 @@ +//! The admin utils. Mirrors `src/utils/admin-{grafana,health,metrics,password,rate-limit, +//! session}.ts` (the settings-family exception, `admin-notifications-settings.ts`, belongs to +//! `nostream-config`). + +pub mod grafana; +pub mod health; +pub mod metrics; +pub mod password; +pub mod rate_limit; +pub mod session; diff --git a/crates/nostream-admin/src/utils/password.rs b/crates/nostream-admin/src/utils/password.rs new file mode 100644 index 000000000..ed2a3651a --- /dev/null +++ b/crates/nostream-admin/src/utils/password.rs @@ -0,0 +1,10 @@ +//! `admin-password.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will hashes and verifies passwords; see `src/utils/admin-password.ts`. +pub fn run() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::utils::password", + }) +} diff --git a/crates/nostream-admin/src/utils/rate_limit.rs b/crates/nostream-admin/src/utils/rate_limit.rs new file mode 100644 index 000000000..d5ed4ad4a --- /dev/null +++ b/crates/nostream-admin/src/utils/rate_limit.rs @@ -0,0 +1,10 @@ +//! `admin-rate-limit.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will applies the admin rate limits; see `src/utils/admin-rate-limit.ts`. +pub fn run() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::utils::rate_limit", + }) +} diff --git a/crates/nostream-admin/src/utils/session.rs b/crates/nostream-admin/src/utils/session.rs new file mode 100644 index 000000000..752029966 --- /dev/null +++ b/crates/nostream-admin/src/utils/session.rs @@ -0,0 +1,10 @@ +//! `admin-session.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will stores and checks admin sessions; see `src/utils/admin-session.ts`. +pub fn run() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::utils::session", + }) +} From 75d396419ba40941d8628c9a891589a4a3dc46ed Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 15:34:58 -0400 Subject: [PATCH 10/32] feat(cli): the config subcommands and the command skeleton config validate (one 'path: message' line per issue, exit 2 on any issue, the clean-document confirmation otherwise) and config get [--json] run end to end over nostream-config; the other ten commands, the TUI and the docker/process supervisors are labelled stubs. clap in the bin; anyhow only in the binary's error handling (spec D10). --- Cargo.lock | 176 +++++++++++++++++++++ crates/nostream-cli/Cargo.toml | 27 ++++ crates/nostream-cli/src/commands/config.rs | 24 +++ crates/nostream-cli/src/commands/dev.rs | 10 ++ crates/nostream-cli/src/commands/export.rs | 10 ++ crates/nostream-cli/src/commands/import.rs | 10 ++ crates/nostream-cli/src/commands/info.rs | 10 ++ crates/nostream-cli/src/commands/invite.rs | 10 ++ crates/nostream-cli/src/commands/mod.rs | 14 ++ crates/nostream-cli/src/commands/seed.rs | 10 ++ crates/nostream-cli/src/commands/setup.rs | 10 ++ crates/nostream-cli/src/commands/start.rs | 10 ++ crates/nostream-cli/src/commands/stop.rs | 10 ++ crates/nostream-cli/src/commands/update.rs | 10 ++ crates/nostream-cli/src/config.rs | 117 ++++++++++++++ crates/nostream-cli/src/docker.rs | 10 ++ crates/nostream-cli/src/error.rs | 15 ++ crates/nostream-cli/src/lib.rs | 21 +++ crates/nostream-cli/src/main.rs | 118 ++++++++++++++ crates/nostream-cli/src/process.rs | 10 ++ crates/nostream-cli/src/tui.rs | 8 + crates/nostream-cli/tests/config_cli.rs | 63 ++++++++ crates/nostream-config/src/lib.rs | 2 +- 23 files changed, 704 insertions(+), 1 deletion(-) create mode 100644 crates/nostream-cli/Cargo.toml create mode 100644 crates/nostream-cli/src/commands/config.rs create mode 100644 crates/nostream-cli/src/commands/dev.rs create mode 100644 crates/nostream-cli/src/commands/export.rs create mode 100644 crates/nostream-cli/src/commands/import.rs create mode 100644 crates/nostream-cli/src/commands/info.rs create mode 100644 crates/nostream-cli/src/commands/invite.rs create mode 100644 crates/nostream-cli/src/commands/mod.rs create mode 100644 crates/nostream-cli/src/commands/seed.rs create mode 100644 crates/nostream-cli/src/commands/setup.rs create mode 100644 crates/nostream-cli/src/commands/start.rs create mode 100644 crates/nostream-cli/src/commands/stop.rs create mode 100644 crates/nostream-cli/src/commands/update.rs create mode 100644 crates/nostream-cli/src/config.rs create mode 100644 crates/nostream-cli/src/docker.rs create mode 100644 crates/nostream-cli/src/error.rs create mode 100644 crates/nostream-cli/src/lib.rs create mode 100644 crates/nostream-cli/src/main.rs create mode 100644 crates/nostream-cli/src/process.rs create mode 100644 crates/nostream-cli/src/tui.rs create mode 100644 crates/nostream-cli/tests/config_cli.rs diff --git a/Cargo.lock b/Cargo.lock index 5bf052d0a..42153d39c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,62 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "bincode" version = "1.3.3" @@ -42,6 +98,52 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "crossbeam-queue" version = "0.3.14" @@ -131,6 +233,12 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "heed" version = "0.20.5" @@ -283,6 +391,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -335,6 +449,30 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "nostream-admin" +version = "0.1.0" +dependencies = [ + "nostream-config", + "nostream-core", + "nostream-storage", + "thiserror", +] + +[[package]] +name = "nostream-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "nostream-config", + "nostream-core", + "serde_json", + "serde_yaml", + "tempfile", + "thiserror", +] + [[package]] name = "nostream-config" version = "0.1.0" @@ -356,6 +494,26 @@ dependencies = [ "tokio", ] +[[package]] +name = "nostream-relay" +version = "0.1.0" +dependencies = [ + "nostream-config", + "nostream-core", + "nostream-storage", + "thiserror", +] + +[[package]] +name = "nostream-services" +version = "0.1.0" +dependencies = [ + "nostream-config", + "nostream-core", + "nostream-storage", + "thiserror", +] + [[package]] name = "nostream-storage" version = "0.1.0" @@ -374,6 +532,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "page_size" version = "0.6.0" @@ -605,6 +769,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.119" @@ -746,6 +916,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/crates/nostream-cli/Cargo.toml b/crates/nostream-cli/Cargo.toml new file mode 100644 index 000000000..1eceabdc2 --- /dev/null +++ b/crates/nostream-cli/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "nostream-cli" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "The operator command line" + +[[bin]] +name = "nostream-cli" +path = "src/main.rs" + +[dependencies] +clap.workspace = true +anyhow = { workspace = true } +nostream-config.workspace = true +nostream-core.workspace = true +serde_json.workspace = true +serde_yaml.workspace = true +thiserror.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/nostream-cli/src/commands/config.rs b/crates/nostream-cli/src/commands/config.rs new file mode 100644 index 000000000..45d7b0e87 --- /dev/null +++ b/crates/nostream-cli/src/commands/config.rs @@ -0,0 +1,24 @@ +//! The `config` subcommand: validate and get, on top of `crate::config`. + +use crate::config::{CommandOutput, resolve_paths}; +use crate::error::CliError; +use std::path::Path; + +/// Runs `config validate`. +pub fn validate( + cwd: &Path, + config_dir: Option<&Path>, + config_file: Option<&Path>, +) -> Result { + crate::config::validate(&resolve_paths(cwd, config_dir), config_file) +} + +/// Runs `config get `. +pub fn get( + cwd: &Path, + config_dir: Option<&Path>, + path: &str, + json: bool, +) -> Result { + crate::config::get(&resolve_paths(cwd, config_dir), path, json) +} diff --git a/crates/nostream-cli/src/commands/dev.rs b/crates/nostream-cli/src/commands/dev.rs new file mode 100644 index 000000000..2b78ebda9 --- /dev/null +++ b/crates/nostream-cli/src/commands/dev.rs @@ -0,0 +1,10 @@ +//! `dev.ts`. + +use crate::error::CliError; + +/// Phase 1 stub — job 005; will drives the local development loop; see `src/cli/commands/dev.ts`. +pub fn run() -> Result<(), CliError> { + Err(CliError::NotImplemented { + module: "cli::commands::dev", + }) +} diff --git a/crates/nostream-cli/src/commands/export.rs b/crates/nostream-cli/src/commands/export.rs new file mode 100644 index 000000000..37c3730d8 --- /dev/null +++ b/crates/nostream-cli/src/commands/export.rs @@ -0,0 +1,10 @@ +//! `export.ts`. + +use crate::error::CliError; + +/// Phase 1 stub — job 005; will exports events to a file; see `src/cli/commands/export.ts`. +pub fn run() -> Result<(), CliError> { + Err(CliError::NotImplemented { + module: "cli::commands::export", + }) +} diff --git a/crates/nostream-cli/src/commands/import.rs b/crates/nostream-cli/src/commands/import.rs new file mode 100644 index 000000000..a383a4baf --- /dev/null +++ b/crates/nostream-cli/src/commands/import.rs @@ -0,0 +1,10 @@ +//! `import.ts`. + +use crate::error::CliError; + +/// Phase 1 stub — job 005; will imports events from a file; see `src/cli/commands/import.ts`. +pub fn run() -> Result<(), CliError> { + Err(CliError::NotImplemented { + module: "cli::commands::import", + }) +} diff --git a/crates/nostream-cli/src/commands/info.rs b/crates/nostream-cli/src/commands/info.rs new file mode 100644 index 000000000..7a2fc639e --- /dev/null +++ b/crates/nostream-cli/src/commands/info.rs @@ -0,0 +1,10 @@ +//! `info.ts`. + +use crate::error::CliError; + +/// Phase 1 stub — job 005; will prints the relay NIP-11 info; see `src/cli/commands/info.ts`. +pub fn run() -> Result<(), CliError> { + Err(CliError::NotImplemented { + module: "cli::commands::info", + }) +} diff --git a/crates/nostream-cli/src/commands/invite.rs b/crates/nostream-cli/src/commands/invite.rs new file mode 100644 index 000000000..f64fae987 --- /dev/null +++ b/crates/nostream-cli/src/commands/invite.rs @@ -0,0 +1,10 @@ +//! `invite.ts`. + +use crate::error::CliError; + +/// Phase 1 stub — job 005; will mints and manages invite codes; see `src/cli/commands/invite.ts`. +pub fn run() -> Result<(), CliError> { + Err(CliError::NotImplemented { + module: "cli::commands::invite", + }) +} diff --git a/crates/nostream-cli/src/commands/mod.rs b/crates/nostream-cli/src/commands/mod.rs new file mode 100644 index 000000000..3fe4416eb --- /dev/null +++ b/crates/nostream-cli/src/commands/mod.rs @@ -0,0 +1,14 @@ +//! The CLI commands. `config` is implemented; the rest are labelled stubs for the Phase-2 +//! process supervisor. + +pub mod config; +pub mod dev; +pub mod export; +pub mod import; +pub mod info; +pub mod invite; +pub mod seed; +pub mod setup; +pub mod start; +pub mod stop; +pub mod update; diff --git a/crates/nostream-cli/src/commands/seed.rs b/crates/nostream-cli/src/commands/seed.rs new file mode 100644 index 000000000..ea2c28c30 --- /dev/null +++ b/crates/nostream-cli/src/commands/seed.rs @@ -0,0 +1,10 @@ +//! `seed.ts`. + +use crate::error::CliError; + +/// Phase 1 stub — job 005; will seeds the database; see `src/cli/commands/seed.ts`. +pub fn run() -> Result<(), CliError> { + Err(CliError::NotImplemented { + module: "cli::commands::seed", + }) +} diff --git a/crates/nostream-cli/src/commands/setup.rs b/crates/nostream-cli/src/commands/setup.rs new file mode 100644 index 000000000..ce299a94c --- /dev/null +++ b/crates/nostream-cli/src/commands/setup.rs @@ -0,0 +1,10 @@ +//! `setup.ts`. + +use crate::error::CliError; + +/// Phase 1 stub — job 005; will runs the first-time setup flow; see `src/cli/commands/setup.ts`. +pub fn run() -> Result<(), CliError> { + Err(CliError::NotImplemented { + module: "cli::commands::setup", + }) +} diff --git a/crates/nostream-cli/src/commands/start.rs b/crates/nostream-cli/src/commands/start.rs new file mode 100644 index 000000000..b625cd1cd --- /dev/null +++ b/crates/nostream-cli/src/commands/start.rs @@ -0,0 +1,10 @@ +//! `start.ts`. + +use crate::error::CliError; + +/// Phase 1 stub — job 005; will starts the relay stack; see `src/cli/commands/start.ts`. +pub fn run() -> Result<(), CliError> { + Err(CliError::NotImplemented { + module: "cli::commands::start", + }) +} diff --git a/crates/nostream-cli/src/commands/stop.rs b/crates/nostream-cli/src/commands/stop.rs new file mode 100644 index 000000000..5124a2781 --- /dev/null +++ b/crates/nostream-cli/src/commands/stop.rs @@ -0,0 +1,10 @@ +//! `stop.ts`. + +use crate::error::CliError; + +/// Phase 1 stub — job 005; will stops the relay stack; see `src/cli/commands/stop.ts`. +pub fn run() -> Result<(), CliError> { + Err(CliError::NotImplemented { + module: "cli::commands::stop", + }) +} diff --git a/crates/nostream-cli/src/commands/update.rs b/crates/nostream-cli/src/commands/update.rs new file mode 100644 index 000000000..83c6e6891 --- /dev/null +++ b/crates/nostream-cli/src/commands/update.rs @@ -0,0 +1,10 @@ +//! `update.ts`. + +use crate::error::CliError; + +/// Phase 1 stub — job 005; will updates the deployment; see `src/cli/commands/update.ts`. +pub fn run() -> Result<(), CliError> { + Err(CliError::NotImplemented { + module: "cli::commands::update", + }) +} diff --git a/crates/nostream-cli/src/config.rs b/crates/nostream-cli/src/config.rs new file mode 100644 index 000000000..557a6d1f2 --- /dev/null +++ b/crates/nostream-cli/src/config.rs @@ -0,0 +1,117 @@ +//! The config subcommands' shared logic, on top of `nostream-config`. +//! +//! Defaults resolve from the process working directory (`resources/default-settings.yaml`), +//! exactly as `src/utils/settings-paths.ts` does; `--config-dir` overrides `NOSTR_CONFIG_DIR`. + +use std::path::Path; +use std::process::ExitCode; + +use nostream_config::path::get_by_path; +use nostream_config::{ + SettingsPaths, load, load_defaults, load_document, load_user_settings, merge, validate_document, +}; +use serde_yaml::Value; + +use crate::error::CliError; + +/// Resolves the paths, honouring `--config-dir` over the environment. +pub fn resolve_paths(cwd: &Path, config_dir: Option<&Path>) -> SettingsPaths { + match config_dir { + Some(dir) => SettingsPaths::from_config_dir(cwd, dir), + None => SettingsPaths::resolve(cwd), + } +} + +/// The outcome of one command: lines to print and the process exit code. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct CommandOutput { + pub lines: Vec, + pub exit_code: u8, +} + +impl CommandOutput { + pub fn exit_code(&self) -> ExitCode { + ExitCode::from(self.exit_code) + } +} + +/// `config validate`: print one `path: message` line per issue and exit 2, or confirm a clean +/// document and exit 0. +pub fn validate( + paths: &SettingsPaths, + config_file: Option<&Path>, +) -> Result { + let defaults = load_defaults(&paths.default_settings_file) + .map_err(|error| CliError::Config(error.to_string()))?; + let user = match config_file { + Some(file) => { + Some(load_document(file).map_err(|error| CliError::Config(error.to_string()))?) + } + None => load_user_settings(&paths.config_base_dir) + .map_err(|error| CliError::Config(error.to_string()))?, + }; + let merged = match user { + Some(user) => merge(&defaults, &user), + None => defaults.clone(), + }; + + let issues = validate_document(&merged, &defaults); + if issues.is_empty() { + return Ok(CommandOutput { + lines: vec!["settings valid: 0 issues".to_owned()], + exit_code: 0, + }); + } + + Ok(CommandOutput { + lines: issues + .iter() + .map(|issue| issue.to_string()) + .chain(std::iter::once(format!( + "settings invalid: {} issues", + issues.len() + ))) + .collect(), + exit_code: 2, + }) +} + +/// `config get`: print the value at a dot-path from the effective settings. +pub fn get(paths: &SettingsPaths, path: &str, json: bool) -> Result { + let defaults = load_defaults(&paths.default_settings_file) + .map_err(|error| CliError::Config(error.to_string()))?; + let user = load_user_settings(&paths.config_base_dir) + .map_err(|error| CliError::Config(error.to_string()))?; + let merged = match user { + Some(user) => merge(&defaults, &user), + None => defaults.clone(), + }; + + let Some(value) = + get_by_path(&merged, path).map_err(|error| CliError::Config(error.to_string()))? + else { + return Err(CliError::Config(format!("settings path not found: {path}"))); + }; + + let rendered = if json { + serde_json::to_string(&value).map_err(|error| CliError::Config(error.to_string()))? + } else { + match value { + Value::String(text) => text.clone(), + Value::Null => "null".to_owned(), + other => { + serde_json::to_string(other).map_err(|error| CliError::Config(error.to_string()))? + } + } + }; + + Ok(CommandOutput { + lines: vec![rendered], + exit_code: 0, + }) +} + +/// Loads the effective settings the way the daemon does (defaults on any failure). +pub fn effective(paths: &SettingsPaths) -> Result { + load(paths).map_err(|error| CliError::Config(error.to_string())) +} diff --git a/crates/nostream-cli/src/docker.rs b/crates/nostream-cli/src/docker.rs new file mode 100644 index 000000000..473b0fb94 --- /dev/null +++ b/crates/nostream-cli/src/docker.rs @@ -0,0 +1,10 @@ +//! The docker supervisor. Mirrors `src/cli/utils/docker.ts`. + +use crate::error::CliError; + +/// Phase 1 stub — job 005; will run compose commands against the deployment; see `src/cli/utils/docker.ts`. +pub fn compose(_args: &[String]) -> Result { + Err(CliError::NotImplemented { + module: "cli::docker::compose", + }) +} diff --git a/crates/nostream-cli/src/error.rs b/crates/nostream-cli/src/error.rs new file mode 100644 index 000000000..251030fd6 --- /dev/null +++ b/crates/nostream-cli/src/error.rs @@ -0,0 +1,15 @@ +//! The CLI error type. `anyhow` lives in the binary; the library keeps its own type. + +use thiserror::Error; + +/// Errors raised by the CLI library. +#[derive(Debug, Error)] +pub enum CliError { + /// A Phase 1 stub. The module names the call that is not implemented yet. + #[error("not implemented in Phase 1 (job 005): {module}")] + NotImplemented { module: &'static str }, + + /// A config file or path could not be read. + #[error("{0}")] + Config(String), +} diff --git a/crates/nostream-cli/src/lib.rs b/crates/nostream-cli/src/lib.rs new file mode 100644 index 000000000..752235168 --- /dev/null +++ b/crates/nostream-cli/src/lib.rs @@ -0,0 +1,21 @@ +//! `nostream-cli` owns the operator command line: the `config` subcommands (implemented on top +//! of `nostream-config`), the operational scripts and the process supervisor. +//! +//! It owns these TypeScript paths from the capture commit +//! (`242a3001d77149e4d70151ec03060c4ef44a1547`): +//! +//! - `src/cli/` (28 files: commands, TUI, utils) +//! - `src/scripts/{benchmark-queries,export-events}.ts` +//! - `src/import-events.ts`, `src/clean-db.ts` +//! +//! Phase 1 implements `config validate` and `config get` end to end; the remaining commands, +//! the TUI and the docker/process supervisors are labelled stubs (Phase 2). + +pub mod commands; +pub mod config; +pub mod docker; +pub mod error; +pub mod process; +pub mod tui; + +pub use error::CliError; diff --git a/crates/nostream-cli/src/main.rs b/crates/nostream-cli/src/main.rs new file mode 100644 index 000000000..7270967d2 --- /dev/null +++ b/crates/nostream-cli/src/main.rs @@ -0,0 +1,118 @@ +//! `nostream-cli` — the operator command line. +//! +//! `anyhow` is allowed here (binaries only, spec D10); every subcommand handler lives in the +//! library so the error model stays typed. + +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::{Parser, Subcommand}; +use nostream_cli::config::CommandOutput; +use nostream_cli::{CliError, commands}; + +#[derive(Debug, Parser)] +#[command(name = "nostream-cli", about = "Operate a nostream relay")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Inspect and validate the effective settings. + Config { + #[command(subcommand)] + command: ConfigCommand, + }, + /// Drive the local development loop. + Dev, + /// Export events to a file. + Export, + /// Import events from a file. + Import, + /// Print the relay's NIP-11 info. + Info, + /// Mint and manage invite codes. + Invite, + /// Seed the database. + Seed, + /// Run the first-time setup flow. + Setup, + /// Start the relay stack. + Start, + /// Stop the relay stack. + Stop, + /// Update the deployment. + Update, + /// Run the interactive settings editor. + Tui, +} + +#[derive(Debug, Subcommand)] +enum ConfigCommand { + /// Validate the effective settings and print one `path: message` line per issue. + Validate { + /// The configuration directory (overrides NOSTR_CONFIG_DIR). + #[arg(long)] + config_dir: Option, + /// Validate this file instead of the configured settings. + #[arg(long)] + config: Option, + }, + /// Print the effective value at a dot-path. + Get { + /// The dot-path to read (for example `info.relay_url`). + path: String, + /// The configuration directory (overrides NOSTR_CONFIG_DIR). + #[arg(long)] + config_dir: Option, + /// Print the value as JSON. + #[arg(long)] + json: bool, + }, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let result = match cli.command { + Command::Config { command } => match command { + ConfigCommand::Validate { config_dir, config } => { + commands::config::validate(&cwd, config_dir.as_deref(), config.as_deref()) + } + ConfigCommand::Get { + path, + config_dir, + json, + } => commands::config::get(&cwd, config_dir.as_deref(), &path, json), + }, + Command::Dev => commands::dev::run().map(|_| CommandOutput::default()), + Command::Export => commands::export::run().map(|_| CommandOutput::default()), + Command::Import => commands::import::run().map(|_| CommandOutput::default()), + Command::Info => commands::info::run().map(|_| CommandOutput::default()), + Command::Invite => commands::invite::run().map(|_| CommandOutput::default()), + Command::Seed => commands::seed::run().map(|_| CommandOutput::default()), + Command::Setup => commands::setup::run().map(|_| CommandOutput::default()), + Command::Start => commands::start::run().map(|_| CommandOutput::default()), + Command::Stop => commands::stop::run().map(|_| CommandOutput::default()), + Command::Update => commands::update::run().map(|_| CommandOutput::default()), + Command::Tui => nostream_cli::tui::run().map(|_| CommandOutput::default()), + }; + + match result { + Ok(output) => { + for line in &output.lines { + println!("{line}"); + } + output.exit_code() + } + Err(CliError::NotImplemented { module }) => { + eprintln!("not implemented in Phase 1 (job 005): {module}"); + ExitCode::from(1) + } + Err(CliError::Config(message)) => { + eprintln!("{message}"); + ExitCode::from(1) + } + } +} diff --git a/crates/nostream-cli/src/process.rs b/crates/nostream-cli/src/process.rs new file mode 100644 index 000000000..3cd0677d1 --- /dev/null +++ b/crates/nostream-cli/src/process.rs @@ -0,0 +1,10 @@ +//! The process supervisor. Mirrors `src/cli/utils/process.ts`. + +use crate::error::CliError; + +/// Phase 1 stub — job 005; will spawn and supervise child processes; see `src/cli/utils/process.ts`. +pub fn spawn(_command: &str) -> Result { + Err(CliError::NotImplemented { + module: "cli::process::spawn", + }) +} diff --git a/crates/nostream-cli/src/tui.rs b/crates/nostream-cli/src/tui.rs new file mode 100644 index 000000000..17cc5d97e --- /dev/null +++ b/crates/nostream-cli/src/tui.rs @@ -0,0 +1,8 @@ +//! The interactive TUI. Mirrors `src/cli/tui/**`. + +use crate::error::CliError; + +/// Phase 1 stub — job 005; will run the guided settings editor; see `src/cli/tui/main.ts`. +pub fn run() -> Result<(), CliError> { + Err(CliError::NotImplemented { module: "cli::tui" }) +} diff --git a/crates/nostream-cli/tests/config_cli.rs b/crates/nostream-cli/tests/config_cli.rs new file mode 100644 index 000000000..ecc2c794e --- /dev/null +++ b/crates/nostream-cli/tests/config_cli.rs @@ -0,0 +1,63 @@ +//! End-to-end tests for the `config` subcommands. + +use std::fs; +use std::path::PathBuf; + +use nostream_cli::commands::config; +use nostream_cli::config::resolve_paths; + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../") + .canonicalize() + .expect("the workspace root exists") +} + +#[test] +fn config_get_prints_the_relay_url() { + let output = config::get(&workspace_root(), None, "info.relay_url", false).expect("get"); + assert_eq!(output.lines, ["wss://nostream.your-domain.com"]); + assert_eq!(output.exit_code, 0); +} + +#[test] +fn config_get_json_round_trips() { + let output = config::get(&workspace_root(), None, "info.pubkey", true).expect("get"); + assert_eq!(output.lines, ["\"replace-with-your-pubkey-in-hex\""]); +} + +#[test] +fn config_validate_reports_the_clean_document() { + let output = config::validate(&workspace_root(), None, None).expect("validate"); + assert_eq!(output.lines, ["settings valid: 0 issues"]); + assert_eq!(output.exit_code, 0); +} + +#[test] +fn config_validate_reports_issues_from_the_config_file() { + let directory = tempfile::tempdir().expect("tempdir"); + fs::write( + directory.path().join("settings.yaml"), + "limits:\n rateLimiter:\n strategy: random\n", + ) + .expect("write"); + let output = + config::validate(&workspace_root(), Some(directory.path()), None).expect("validate"); + assert_eq!(output.exit_code, 2); + assert!(output.lines[0].starts_with("limits.rateLimiter.strategy: ")); + assert_eq!( + output.lines.last().map(String::as_str), + Some("settings invalid: 1 issues") + ); +} + +#[test] +fn the_paths_resolver_honours_the_config_dir() { + let directory = tempfile::tempdir().expect("tempdir"); + let paths = resolve_paths(&workspace_root(), Some(directory.path())); + assert_eq!( + paths.default_settings_file, + workspace_root().join("resources/default-settings.yaml") + ); + assert_eq!(paths.config_base_dir, directory.path().to_path_buf()); +} diff --git a/crates/nostream-config/src/lib.rs b/crates/nostream-config/src/lib.rs index 90a4a8a7e..a64b87370 100644 --- a/crates/nostream-config/src/lib.rs +++ b/crates/nostream-config/src/lib.rs @@ -33,7 +33,7 @@ pub mod value; pub use error::ConfigError; pub use issue::Issue; -pub use load::{LoadOutcome, load, load_defaults, load_user_settings, merge}; +pub use load::{LoadOutcome, load, load_defaults, load_document, load_user_settings, merge}; pub use paths::SettingsPaths; pub use settings::Settings; pub use validate::{validate, validate_document}; From d405463746b6a046288002a005efdc529aa93234 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 15:39:35 -0400 Subject: [PATCH 11/32] feat(daemon): the nostreamd runtime with a single acceptor and drain-on-signal The daemon binary (--config-dir/--check/--listen-port/--log-level) with the settings gate (per-issue lines, exit 2), telemetry init (stdout subscriber now, OTLP behind the 'otlp' feature reading OTEL_EXPORTER_OTLP_ENDPOINT), the single acceptor task that binds, accepts and drops sockets with a debug line, and the SIGINT/SIGTERM drain through core::lifecycle::ShutdownState. Runtime decision recorded in ADR 0001: no SO_REUSEPORT sharding in Phase 1. --- Cargo.lock | 830 +++++++++++++++++++++++++- crates/nostream-core/src/lifecycle.rs | 5 + crates/nostreamd/Cargo.toml | 39 ++ crates/nostreamd/src/acceptor.rs | 69 +++ crates/nostreamd/src/error.rs | 19 + crates/nostreamd/src/lib.rs | 25 + crates/nostreamd/src/main.rs | 141 +++++ crates/nostreamd/src/telemetry.rs | 35 ++ crates/nostreamd/src/workers.rs | 19 + crates/nostreamd/tests/acceptor.rs | 35 ++ 10 files changed, 1213 insertions(+), 4 deletions(-) create mode 100644 crates/nostreamd/Cargo.toml create mode 100644 crates/nostreamd/src/acceptor.rs create mode 100644 crates/nostreamd/src/error.rs create mode 100644 crates/nostreamd/src/lib.rs create mode 100644 crates/nostreamd/src/main.rs create mode 100644 crates/nostreamd/src/telemetry.rs create mode 100644 crates/nostreamd/src/workers.rs create mode 100644 crates/nostreamd/tests/acceptor.rs diff --git a/Cargo.lock b/Cargo.lock index 42153d39c..a44ef9956 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "1.0.0" @@ -58,6 +67,29 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bincode" version = "1.3.3" @@ -76,12 +108,24 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cc" version = "1.8.0" @@ -179,6 +223,12 @@ dependencies = [ "phf", ] +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + [[package]] name = "equivalent" version = "1.0.3" @@ -207,6 +257,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -216,6 +272,81 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -224,7 +355,26 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", +] + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", ] [[package]] @@ -277,6 +427,100 @@ dependencies = [ "serde_json", ] +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "icu_collections" version = "2.3.0" @@ -397,12 +641,38 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "libc" version = "0.2.191" @@ -432,6 +702,21 @@ dependencies = [ "libc", ] +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.8.3" @@ -526,6 +811,35 @@ dependencies = [ "tokio", ] +[[package]] +name = "nostreamd" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "nostream-config", + "nostream-core", + "nostream-relay", + "nostream-storage", + "opentelemetry", + "opentelemetry-otlp", + "tempfile", + "thiserror", + "tokio", + "tracing", + "tracing-opentelemetry", + "tracing-subscriber", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -538,6 +852,66 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opentelemetry" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c1f1a6ac9c875a4941e4c7c085b61e8f4daa326de03b4701768c3491a6ad9ee" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", + "tracing", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0756af24841e367128ef31c831999937b8b5c5197d47914590d8f4e2e88c2e1a" +dependencies = [ + "http", + "opentelemetry", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", + "thiserror", + "tokio", + "tonic", + "tonic-types", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d576f5dddc3299341bcd85a04be17b2564844c0c9563214f02d85f2524d1afae" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost", + "tonic", + "tonic-prost", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "372a912db2974d1990207006fd0e3aa0ef3fd4ad3be43c65cfaadf2206866061" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "portable-atomic", + "rand 0.9.5", + "thiserror", +] + [[package]] name = "page_size" version = "0.6.0" @@ -571,7 +945,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.8", ] [[package]] @@ -596,12 +970,38 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + [[package]] name = "potential_utf" version = "0.1.6" @@ -611,6 +1011,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -620,6 +1029,38 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + [[package]] name = "quote" version = "1.0.47" @@ -629,6 +1070,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -641,7 +1088,27 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -650,6 +1117,32 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rustix" version = "1.1.5" @@ -663,6 +1156,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "ryu" version = "1.0.23" @@ -725,6 +1224,15 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "2.0.1" @@ -747,6 +1255,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.16.3" @@ -797,6 +1311,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synchronoise" version = "1.0.1" @@ -824,7 +1344,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys", @@ -850,6 +1370,15 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -866,6 +1395,7 @@ version = "1.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" dependencies = [ + "bytes", "libc", "mio", "pin-project-lite", @@ -886,6 +1416,193 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "base64", + "bytes", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-types" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8" +dependencies = [ + "prost", + "prost-types", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-opentelemetry" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a904802a1b902f43638b677ff2a650847e3b4404101b6c586d648e8c1e3e8fe" +dependencies = [ + "js-sys", + "opentelemetry", + "smallvec", + "tracing", + "tracing-core", + "tracing-log", + "tracing-subscriber", + "web-time", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "unicode-ident" version = "1.0.27" @@ -922,12 +1639,91 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" @@ -965,6 +1761,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" version = "0.6.4" @@ -994,6 +1796,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/crates/nostream-core/src/lifecycle.rs b/crates/nostream-core/src/lifecycle.rs index 12f8d259a..d8d038656 100644 --- a/crates/nostream-core/src/lifecycle.rs +++ b/crates/nostream-core/src/lifecycle.rs @@ -35,6 +35,11 @@ impl ShutdownState { *self.sender.borrow() } + /// A receiver for the drain flag, for tasks that select on it. + pub fn watch(&self) -> watch::Receiver { + self.sender.subscribe() + } + /// Resolves once `begin_draining` has been called (immediately if it already has). pub async fn wait_for_drain(&self) { let mut receiver = self.sender.subscribe(); diff --git a/crates/nostreamd/Cargo.toml b/crates/nostreamd/Cargo.toml new file mode 100644 index 000000000..5060a4077 --- /dev/null +++ b/crates/nostreamd/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "nostreamd" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "The relay daemon: runtime, acceptor, telemetry and signals" + +[[bin]] +name = "nostreamd" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +clap.workspace = true +nostream-config.workspace = true +nostream-core.workspace = true +nostream-relay.workspace = true +nostream-storage.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +thiserror.workspace = true + +[target.'cfg(not(target_env = "musl"))'.dependencies] +opentelemetry = { workspace = true, optional = true } +opentelemetry-otlp = { workspace = true, optional = true } +tracing-opentelemetry = { workspace = true, optional = true } + +[features] +default = [] +otlp = ["dep:opentelemetry", "dep:opentelemetry-otlp", "dep:tracing-opentelemetry"] + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/nostreamd/src/acceptor.rs b/crates/nostreamd/src/acceptor.rs new file mode 100644 index 000000000..8939d6881 --- /dev/null +++ b/crates/nostreamd/src/acceptor.rs @@ -0,0 +1,69 @@ +//! The acceptor: one task owning the listener, accepting sockets, handing each to the relay. +//! +//! This is the Phase-1 runtime decision (ADR 0001): a **single acceptor task**, no +//! `SO_REUSEPORT`, no per-core acceptor sharding — the TS relay is a single Node process, and +//! the multi-worker fan-out is a Phase-2 decision to take against measurements, not upfront. + +use std::net::SocketAddr; + +use tokio::net::TcpListener; +use tokio::sync::watch; + +use crate::error::DaemonError; + +/// The acceptor's handle. +#[derive(Debug)] +pub struct Acceptor { + listener: TcpListener, + local_addr: SocketAddr, +} + +impl Acceptor { + /// Binds the listener on `addr`. + pub async fn bind(addr: SocketAddr) -> Result { + let listener = TcpListener::bind(addr) + .await + .map_err(|error| DaemonError::Setup(format!("bind {addr}: {error}")))?; + let local_addr = listener + .local_addr() + .map_err(|error| DaemonError::Setup(format!("local_addr: {error}")))?; + Ok(Self { + listener, + local_addr, + }) + } + + /// The bound address (an ephemeral port resolves to the real one). + pub fn local_addr(&self) -> SocketAddr { + self.local_addr + } + + /// Accepts sockets until `shutdown` fires; every socket is dropped with a debug line in + /// Phase 1 — the relay wire takes the socket in Phase 2. + pub async fn run(&self, mut shutdown: watch::Receiver) -> Result<(), DaemonError> { + loop { + let shutting_down = *shutdown.borrow(); + if shutting_down { + tracing::debug!(at = %self.local_addr, "drained; the acceptor stops accepting"); + return Ok(()); + } + + let (socket, peer) = tokio::select! { + accepted = self.listener.accept() => match accepted { + Ok(pair) => pair, + Err(error) => { + tracing::warn!(error = %error, "accept failed"); + continue; + } + }, + _ = shutdown.changed() => { + tracing::debug!(at = %self.local_addr, "drained; the acceptor stops accepting"); + return Ok(()); + } + }; + + drop(socket); + tracing::debug!(%peer, "accepted a socket; the relay wire is Phase 2, dropping it"); + } + } +} diff --git a/crates/nostreamd/src/error.rs b/crates/nostreamd/src/error.rs new file mode 100644 index 000000000..348145dcd --- /dev/null +++ b/crates/nostreamd/src/error.rs @@ -0,0 +1,19 @@ +//! The daemon's error type. + +use thiserror::Error; + +/// Errors raised by `nostreamd`. +#[derive(Debug, Error)] +pub enum DaemonError { + /// A Phase 1 stub. The module names the call that is not implemented yet. + #[error("not implemented in Phase 1 (job 005): {module}")] + NotImplemented { module: &'static str }, + + /// A socket or file the daemon needs could not be opened. + #[error("daemon setup failed: {0}")] + Setup(String), + + /// Settings failed validation; the daemon refuses to start. + #[error("settings invalid: {count} issues")] + InvalidSettings { count: usize }, +} diff --git a/crates/nostreamd/src/lib.rs b/crates/nostreamd/src/lib.rs new file mode 100644 index 000000000..d1b813741 --- /dev/null +++ b/crates/nostreamd/src/lib.rs @@ -0,0 +1,25 @@ +//! `nostreamd` — the relay daemon: the runtime, the acceptor, telemetry and the signals. +//! +//! It owns these TypeScript paths from the capture commit +//! (`242a3001d77149e4d70151ec03060c4ef44a1547`): +//! +//! - `src/index.ts` and `src/main.ts` (bootstrap, signal handling, worker scheduling) +//! - `src/adapters/{web-socket-server-adapter,web-socket-adapter}.ts` (the listening half) +//! - `src/utils/scheduler.ts` (the recurring-worker loop) +//! - `src/tor/**` (the hidden service bootstrap) +//! +//! Runtime decision (ADR `0001-runtime-and-telemetry.md`): a single acceptor task, no +//! `SO_REUSEPORT` sharding until measurements ask for it. Phase 1 binds, accepts, drains on +//! SIGINT/SIGTERM through `nostream_core::lifecycle::ShutdownState` and drops sockets with a +//! debug line; the relay wire takes the socket in Phase 2. +//! +//! The MCP server named in the issue is Phase 5 and has no TS counterpart at the capture; it +//! has no module here either. + +pub mod acceptor; +pub mod error; +pub mod telemetry; +pub mod workers; + +pub use acceptor::Acceptor; +pub use error::DaemonError; diff --git a/crates/nostreamd/src/main.rs b/crates/nostreamd/src/main.rs new file mode 100644 index 000000000..54760162b --- /dev/null +++ b/crates/nostreamd/src/main.rs @@ -0,0 +1,141 @@ +//! `nostreamd` — the relay daemon entry point. +//! +//! `anyhow` is allowed here (binaries only, spec D10); the library carries the typed errors. + +use std::net::Ipv4Addr; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::Parser; +use nostream_config::SettingsPaths; +use nostream_core::lifecycle::ShutdownState; +use nostreamd::{DaemonError, telemetry}; + +#[derive(Debug, Parser)] +#[command(name = "nostreamd", about = "The nostream relay daemon")] +struct Cli { + /// The configuration directory (overrides NOSTR_CONFIG_DIR). + #[arg(long, global = true)] + config_dir: Option, + + /// Validate the effective settings and exit. + #[arg(long)] + check: bool, + + /// Override the listen port (otherwise PORT, then RELAY_PORT, then 8008, mirroring + /// `src/app/worker.ts:32`). + #[arg(long)] + listen_port: Option, + + /// Override the log level (info, debug, warn, error). + #[arg(long)] + log_level: Option, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + match run(cli) { + Ok(code) => code, + Err(DaemonError::InvalidSettings { count }) => { + eprintln!("settings invalid: {count} issues"); + ExitCode::from(2) + } + Err(DaemonError::NotImplemented { module }) => { + eprintln!("not implemented in Phase 1 (job 005): {module}"); + ExitCode::from(1) + } + Err(error) => { + eprintln!("{error}"); + ExitCode::from(1) + } + } +} + +fn run(cli: Cli) -> Result { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let paths = match &cli.config_dir { + Some(dir) => SettingsPaths::from_config_dir(&cwd, dir), + None => SettingsPaths::resolve(&cwd), + }; + + let outcome = + nostream_config::load(&paths).map_err(|error| DaemonError::Setup(error.to_string()))?; + if !outcome.issues.is_empty() { + for issue in &outcome.issues { + eprintln!("{issue}"); + } + return Err(DaemonError::InvalidSettings { + count: outcome.issues.len(), + }); + } + + if cli.check { + println!("settings valid: 0 issues"); + return Ok(ExitCode::SUCCESS); + } + + telemetry::init(&outcome.settings).map_err(|error| DaemonError::Setup(error.to_string()))?; + + let port = cli + .listen_port + .or_else(|| { + std::env::var("PORT") + .ok() + .and_then(|value| value.parse().ok()) + }) + .or_else(|| { + std::env::var("RELAY_PORT") + .ok() + .and_then(|value| value.parse().ok()) + }) + .unwrap_or(8008u16); + let listen = SocketAddr::from((Ipv4Addr::UNSPECIFIED, port)); + + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| DaemonError::Setup(format!("tokio runtime: {error}")))?; + + runtime.block_on(async move { serve(listen).await }) +} + +async fn serve(listen: SocketAddr) -> Result { + let acceptor = nostreamd::Acceptor::bind(listen).await?; + let shutdown = ShutdownState::new(); + let watch_rx = shutdown.watch(); + + tracing::info!(at = %acceptor.local_addr(), "the acceptor is listening"); + + let accept_task = tokio::spawn(async move { acceptor.run(watch_rx).await }); + let workers_task = tokio::spawn(async { nostreamd::workers::spawn_workers().await }); + let tor_task = tokio::spawn(async { nostreamd::workers::spawn_tor().await }); + + wait_for_signal().await; + tracing::info!("signal received; draining"); + + shutdown.begin_draining(); + let _ = accept_task.await; + let _ = workers_task.await; + let _ = tor_task.await; + + tracing::info!("drained; exiting"); + Ok(ExitCode::SUCCESS) +} + +async fn wait_for_signal() { + #[cfg(unix)] + { + use tokio::signal::unix::{SignalKind, signal}; + let mut sigint = signal(SignalKind::interrupt()).expect("SIGINT stream"); + let mut sigterm = signal(SignalKind::terminate()).expect("SIGTERM stream"); + tokio::select! { + _ = sigint.recv() => {} + _ = sigterm.recv() => {} + } + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} diff --git a/crates/nostreamd/src/telemetry.rs b/crates/nostreamd/src/telemetry.rs new file mode 100644 index 000000000..1eeb1d446 --- /dev/null +++ b/crates/nostreamd/src/telemetry.rs @@ -0,0 +1,35 @@ +//! Telemetry: a tracing subscriber to stdout, plus an OTLP span exporter behind the `otlp` +//! feature (the Phase-5 OTEL exporter; the tracing-otlp crate named in the old spec does not +//! exist — the deviation is recorded in `docs/rewrite/decisions/0001-runtime-and-telemetry.md`). + +use nostream_config::Settings; + +use crate::error::DaemonError; + +/// Installs the global subscriber from the settings. +/// +/// The OTLP endpoint comes from `OTEL_EXPORTER_OTLP_ENDPOINT` (the OTEL SDK convention), not +/// from a settings field: the capture's settings carry no telemetry section. +pub fn init(settings: &Settings) -> Result<(), DaemonError> { + let _ = settings; + #[cfg(feature = "otlp")] + { + if std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").is_ok() { + // The exporter wiring lands with Phase 5; the feature gate and the env convention + // are what Phase 1 pins. + return init_otlp(); + } + } + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .with_target(false) + .try_init() + .map_err(|error| DaemonError::Setup(format!("telemetry: {error}"))) +} + +#[cfg(feature = "otlp")] +fn init_otlp() -> Result<(), DaemonError> { + Err(DaemonError::NotImplemented { + module: "nostreamd::telemetry::init_otlp", + }) +} diff --git a/crates/nostreamd/src/workers.rs b/crates/nostreamd/src/workers.rs new file mode 100644 index 000000000..bfdd953bf --- /dev/null +++ b/crates/nostreamd/src/workers.rs @@ -0,0 +1,19 @@ +//! The background workers. Mirrors `src/services/**` scheduling and the `src/workers/*` +//! families (the capture's worker loop lives in `src/utils/scheduler.ts` and the per-worker +//! files under `src/workers/`). + +use crate::error::DaemonError; + +/// Phase 1 stub — job 005; will schedule the recurring workers; see `src/utils/scheduler.ts`. +pub async fn spawn_workers() -> Result<(), DaemonError> { + Err(DaemonError::NotImplemented { + module: "nostreamd::workers::spawn_workers", + }) +} + +/// Phase 1 stub — job 005; will bootstrap the tor hidden service; see `src/tor/index.ts`. +pub async fn spawn_tor() -> Result<(), DaemonError> { + Err(DaemonError::NotImplemented { + module: "nostreamd::workers::spawn_tor", + }) +} diff --git a/crates/nostreamd/tests/acceptor.rs b/crates/nostreamd/tests/acceptor.rs new file mode 100644 index 000000000..45dd81c66 --- /dev/null +++ b/crates/nostreamd/tests/acceptor.rs @@ -0,0 +1,35 @@ +//! The acceptor's drain behaviour. + +use std::time::Duration; + +use nostream_core::lifecycle::ShutdownState; +use nostreamd::Acceptor; + +#[tokio::test] +async fn the_acceptor_binds_an_ephemeral_port_and_drains_on_shutdown() { + let acceptor = Acceptor::bind("127.0.0.1:0".parse().expect("socket addr")) + .await + .expect("bind"); + let port = acceptor.local_addr().port(); + assert!(port > 0, "the ephemeral port resolved"); + + let shutdown = ShutdownState::new(); + let watch_rx = shutdown.watch(); + + let task = tokio::spawn(async move { acceptor.run(watch_rx).await }); + + // A socket connects and is dropped (Phase 1 accepts and drops; the wire is Phase 2). + let stream = tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .expect("connect"); + drop(stream); + + tokio::time::sleep(Duration::from_millis(50)).await; + + shutdown.begin_draining(); + let result = tokio::time::timeout(Duration::from_secs(2), task) + .await + .expect("the acceptor stops within two seconds") + .expect("join"); + assert!(result.is_ok(), "the acceptor drained cleanly"); +} From 05ccde9825de30b2f4681db7e351240b27faacb0 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 15:41:14 -0400 Subject: [PATCH 12/32] chore(ci): the Rust Checks job, ADR 0001 and the Rust ignore entries checks.yml gains the 'rust' path filter (crates/**, Cargo.{toml,lock}, rust-toolchain.toml, resources/default-settings.yaml, scripts/rust.sh and the workflow itself), adds default-settings.yaml to the 'src' filter, and a Rust Checks job running fmt, both clippy rounds, tests and the release build. ADR 0001 records the runtime/acceptor decision and the telemetry deviation (tracing-otlp does not exist; opentelemetry-otlp + tracing-opentelemetry instead). .gitignore takes /target/; .dockerignore excludes crates and target from the relay image; empty changeset for the changeset-check. --- .changeset/rust-foundation-005.md | 2 + .dockerignore | 4 ++ .github/workflows/checks.yml | 38 ++++++++++++ .gitignore | 3 + .../decisions/0001-runtime-and-telemetry.md | 58 +++++++++++++++++++ 5 files changed, 105 insertions(+) create mode 100644 .changeset/rust-foundation-005.md create mode 100644 docs/rewrite/decisions/0001-runtime-and-telemetry.md diff --git a/.changeset/rust-foundation-005.md b/.changeset/rust-foundation-005.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/rust-foundation-005.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.dockerignore b/.dockerignore index 999ff9e21..f116c659d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,3 +18,7 @@ postgresql.conf test/integration/docker-compose.yml # website tooling (not part of the relay image) website + +# Rust workspace (not part of the relay image) +crates +target diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index b1cf0ae5d..934ea28f3 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -20,6 +20,7 @@ jobs: pull-requests: read outputs: src: ${{ steps.filter.outputs.src }} + rust: ${{ steps.filter.outputs.rust }} steps: - uses: actions/checkout@v4 with: @@ -39,6 +40,15 @@ jobs: - 'Dockerfile*' - 'docker-compose*.yml' - '.nvmrc' + - 'resources/default-settings.yaml' + - '.github/workflows/checks.yml' + rust: + - 'crates/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + - 'resources/default-settings.yaml' + - 'scripts/rust.sh' - '.github/workflows/checks.yml' commit-lint: @@ -207,6 +217,34 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} parallel-finished: true + rust-checks: + name: Rust Checks + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.rust == 'true' + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain-file: rust-toolchain.toml + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + - name: Check formatting + run: cargo fmt --all --check + - name: Clippy (all targets) + run: cargo clippy --workspace --all-targets --all-features -- -D warnings + - name: Clippy (strict, lib+bins) + run: >- + cargo clippy --workspace --lib --bins --all-features -- + -D warnings -D clippy::unwrap_used -D clippy::expect_used + -D clippy::todo -D clippy::unimplemented -D clippy::panic + - name: Tests + run: cargo test --workspace --all-features + - name: Release build + run: cargo build --workspace --all-features --release + changeset-check: name: Changeset Required runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 9648fc276..c2156dc13 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,6 @@ docker-compose.overrides.yml # Code coverage report output coverage-report/ + +# Rust +/target/ diff --git a/docs/rewrite/decisions/0001-runtime-and-telemetry.md b/docs/rewrite/decisions/0001-runtime-and-telemetry.md new file mode 100644 index 000000000..f110c8865 --- /dev/null +++ b/docs/rewrite/decisions/0001-runtime-and-telemetry.md @@ -0,0 +1,58 @@ +# ADR 0001: Runtime, acceptor shape and telemetry (Phase 1) + +- **Status:** accepted (job 005, Phase 1) +- **Deciders:** the rewrite team, on the evidence available at job 005 +- **Date:** 2026-10-17 + +## Context + +The Rust rewrite replaces a Node.js relay that runs one process per worker +(`cluster`-less: `src/index.ts` boots an `AppWorker` directly), listens on +`PORT || RELAY_PORT || 8008` (`src/app/worker.ts:32`), and drains on SIGINT/SIGTERM +through `src/utils/shutdown-state.ts`. The telemetry section of the original spec +named a `tracing-otlp` crate. + +## Decisions + +### D8 — Runtime and acceptor + +1. **tokio multi-thread runtime** for the daemon (`nostreamd`), with `enable_all`. +2. **A single acceptor task** owns the listener. No `SO_REUSEPORT`, no per-core + acceptor sharding in Phase 1. The TS relay is a single Node process; the + multi-worker fan-out is a Phase-2 decision to take against measurements, not + upfront. `Connection::accept` and the relay wire take the socket in Phase 2; + until then sockets are accepted and dropped with a `debug!` line. +3. The listen port resolves as `--listen-port`, then `PORT`, then `RELAY_PORT`, + then `8008` — the capture's rule, in order (`src/app/worker.ts:32`). +4. Drain: SIGINT/SIGTERM set `nostream_core::lifecycle::ShutdownState` + (the port of `src/utils/shutdown-state.ts`); the acceptor's select loop stops + accepting and the process exits 0. + +### D9 — Telemetry + +1. `tracing` + `tracing-subscriber` for spans/logs. **The crate named in the + original spec, `tracing-otlp`, does not exist**; the deviation is to the real + stack: `opentelemetry` + `opentelemetry-otlp` + `tracing-opentelemetry`. +2. Phase 1 ships the stdout subscriber; the OTLP exporter sits behind the + `otlp` feature and is wired in Phase 5 (`initializeMetricsTelemetry` at the + capture reads `OTEL_EXPORTER_OTLP_ENDPOINT`, `src/telemetry/metrics.ts:45`; + with no endpoint the exporter is disabled, `:112` — the Rust side follows the + same env convention, not a settings field, because the settings schema has no + telemetry section). +3. **Metric-name contract:** existing dashboards read + `nostream_
_` snake-case names; the Phase-5 exporter keeps the + names and unit suffixes the TS relay emits (`src/telemetry/metrics.ts`). + +### D15 — One source of truth for shared shapes + +The `Processor` enum (`keyof PaymentsProcessors`), `KindSelector` (untagged) and +`i64` for millisecond-precision timestamps/event ids live in `nostream-config` / +`nostream-core` and are re-exported, not redefined, by dependents. + +## Consequences + +- The daemon is boring to reason about: one listener, one drain flag, no + runtime configuration surface beyond the capture's env vars. +- The `otlp` feature keeps tonic/prost out of the default build until Phase 5. +- If Phase 2 measurements show accept contention, the acceptor splits behind the + same `ShutdownState` without a settings change. From abe1952d1559e434fb8fa5ea601cfabc95ed5062 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 15:42:21 -0400 Subject: [PATCH 13/32] fix(daemon): no expect in the signal path (strict clippy) --- crates/nostreamd/src/main.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/nostreamd/src/main.rs b/crates/nostreamd/src/main.rs index 54760162b..50c7fabb0 100644 --- a/crates/nostreamd/src/main.rs +++ b/crates/nostreamd/src/main.rs @@ -127,12 +127,19 @@ async fn wait_for_signal() { #[cfg(unix)] { use tokio::signal::unix::{SignalKind, signal}; - let mut sigint = signal(SignalKind::interrupt()).expect("SIGINT stream"); - let mut sigterm = signal(SignalKind::terminate()).expect("SIGTERM stream"); - tokio::select! { - _ = sigint.recv() => {} - _ = sigterm.recv() => {} + // A failing signal registration is a process setup failure, not a runtime event: + // warn and fall back to a plain ctrl-c wait so the daemon still drains. + let sigint = signal(SignalKind::interrupt()); + let sigterm = signal(SignalKind::terminate()); + if let (Ok(mut sigint), Ok(mut sigterm)) = (sigint, sigterm) { + tokio::select! { + _ = sigint.recv() => {} + _ = sigterm.recv() => {} + } + return; } + tracing::warn!("signal registration failed; falling back to ctrl-c"); + let _ = tokio::signal::ctrl_c().await; } #[cfg(not(unix))] { From 9666b3202f76a3f97e160c8db6e5d3234a5525c2 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 15:45:54 -0400 Subject: [PATCH 14/32] fix(ci): check rewrite/main on push (spec R21.4) --- .github/workflows/checks.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 934ea28f3..19c32ce90 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -6,6 +6,7 @@ on: push: branches: - main + - rewrite/main concurrency: group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' From b23c02744bf4cad8b8ea1fb6b469bae5f0c0fc09 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 16:20:32 -0400 Subject: [PATCH 15/32] fix(storage,core,relay): dyn-compatible repository traits and review round-1 P3s The nine repository traits return Pin> instead of RPITIT so Arc is writable (regression test boxes two of them); resolved_backend returns StorageError::UnsupportedBackend instead of String; the LMDB unsafe justification states heed 0.20.5's real contract (per-process Env dedup; NO_LOCK/network-fs/map_size preconditions) instead of an invariant heed does not impose; nip66's monitor-kind set is the measured 10166/30166 (50166 is invented); nip77/negentropy cite only files the capture has; Connection::accept takes the transport + shared context rather than a bare SocketAddr; services drops the operator-notifications.ts claim (config owns it); rust.sh falls back to the local tree when the host dir is invisible. --- crates/nostream-core/src/negentropy.rs | 5 +- crates/nostream-core/src/nip66.rs | 7 +- crates/nostream-core/src/nip77.rs | 9 +-- crates/nostream-relay/src/connection.rs | 20 ++++-- crates/nostream-services/src/lib.rs | 3 +- crates/nostream-storage/src/backend.rs | 23 +++++-- crates/nostream-storage/src/error.rs | 5 ++ crates/nostream-storage/src/event_store.rs | 16 +++-- crates/nostream-storage/src/repositories.rs | 69 +++++++++++-------- crates/nostream-storage/tests/storage.rs | 73 +++++++++++++++++++++ scripts/rust.sh | 7 +- 11 files changed, 182 insertions(+), 55 deletions(-) diff --git a/crates/nostream-core/src/negentropy.rs b/crates/nostream-core/src/negentropy.rs index 116484c25..de02f0cfd 100644 --- a/crates/nostream-core/src/negentropy.rs +++ b/crates/nostream-core/src/negentropy.rs @@ -1,5 +1,6 @@ -//! NIP-77 negentropy set reconciliation. Mirrors `src/utils/negentropy.ts` and -//! `src/utils/negentropy-sessions.ts`. +//! NIP-77 negentropy set reconciliation. Mirrors `src/utils/negentropy.ts` (the session +//! bookkeeping named `negentropy-sessions.ts` lives only at the oracle `041fe34e`, not at +//! this branch's capture; the session seam here is this port's own shape). use crate::error::CoreError; diff --git a/crates/nostream-core/src/nip66.rs b/crates/nostream-core/src/nip66.rs index cc00d2d90..910df01a9 100644 --- a/crates/nostream-core/src/nip66.rs +++ b/crates/nostream-core/src/nip66.rs @@ -22,7 +22,10 @@ pub fn parse_discovery(_event: &Event) -> Result { }) } -/// Whether a kind belongs to the NIP-66 monitor family. +/// Whether a kind belongs to the NIP-66 monitor family: the capture names exactly two +/// NIP-66 kinds — `RELAY_MONITOR_ANNOUNCEMENT = 10166` and `RELAY_DISCOVERY = 30166` +/// (`src/constants/base.ts:57,77`); nothing on this branch's tree or in the NIP defines a +/// third. pub fn is_monitor_kind(kind: Kind) -> bool { - matches!(kind.0, 10_166 | 30_166 | 50_166) + matches!(kind.0, 10_166 | 30_166) } diff --git a/crates/nostream-core/src/nip77.rs b/crates/nostream-core/src/nip77.rs index 583b488e2..9a708e64b 100644 --- a/crates/nostream-core/src/nip77.rs +++ b/crates/nostream-core/src/nip77.rs @@ -1,8 +1,9 @@ -//! NIP-77: negentropy sync. Mirrors `src/utils/nip77.ts`. +//! NIP-77: negentropy sync. The protocol types port `src/utils/negentropy.ts` (the +//! reference-implementation port that exists at the capture). //! -//! Phase 1 has the types only. The `neg-open`/`neg-msg`/`neg-close` wire handlers exist at -//! the porting oracle (`041fe34e`) but not at this branch's capture commit, so no handler -//! module is named here. +//! Phase 1 has the types only. The `src/utils/nip77.ts` file and the `neg-open`/`neg-msg`/ +//! `neg-close` wire handlers exist at the porting oracle (`041fe34e`) but not at this +//! branch's capture commit, so no handler module or TS path is claimed for them here. use crate::error::CoreError; diff --git a/crates/nostream-relay/src/connection.rs b/crates/nostream-relay/src/connection.rs index d5600239d..5ded1f745 100644 --- a/crates/nostream-relay/src/connection.rs +++ b/crates/nostream-relay/src/connection.rs @@ -23,11 +23,17 @@ pub struct Connection { } impl Connection { - /// Accepts a socket into the relay's connection table. + /// Accepts one upgraded socket into the relay's connection table. /// /// Phase 1 stub — job 005; will register the socket, apply connection limits and send the - /// NIP-42 challenge; see `src/adapters/web-socket-adapter.ts`. - pub async fn accept(_socket: std::net::SocketAddr) -> Result { + /// NIP-42 challenge; see `src/adapters/web-socket-adapter.ts`. The accepted transport and + /// the shared handler context arrive together because registration, limits and the + /// challenge all read the context — a bare address cannot be registered, limited or + /// challenged, and Phase 2's transport type is decided with the WebSocket dependency. + pub async fn accept( + _transport: crate::connection::AcceptedTransport, + _context: crate::handlers::HandlerContext, + ) -> Result { Err(RelayError::NotImplemented { module: "relay::connection::Connection::accept", }) @@ -43,7 +49,13 @@ impl Connection { } } -/// One negentropy session (`src/utils/negentropy-sessions.ts`). +/// The transport the acceptor hands over. Phase 1 pins the seam, not the socket type: the +/// WebSocket dependency (and with it the real transport) is a Phase-2 decision, so this is +/// an opaque placeholder the stub signatures can name. +#[derive(Debug, Clone, Default)] +pub struct AcceptedTransport; + +/// One negentropy session (oracle `041fe34e`; no session file exists at this branch's capture). #[derive(Debug, Clone, Default)] pub struct NegentropySession { pub subscription_id: Option, diff --git a/crates/nostream-services/src/lib.rs b/crates/nostream-services/src/lib.rs index a442297cd..de74613f4 100644 --- a/crates/nostream-services/src/lib.rs +++ b/crates/nostream-services/src/lib.rs @@ -11,7 +11,8 @@ //! - the NIP-66 monitor: `src/utils/relay-probe/**` (10 files), //! `src/utils/{relay-probe-snapshot,relay-probe-targets,monitor-identity, //! network-health-mismatches,invoice,operator-notification-enqueue}.ts` -//! - `src/@types/{services,dvm,operator-notifications}.ts` +//! - `src/@types/{services,dvm}.ts` (`src/@types/operator-notifications.ts` belongs to +//! `nostream-config` alongside the settings types it joins) //! - `src/factories/{payments-processors/*,maintenance-service-factory, //! notification-outbox-service-factory,payments-service-factory,wot-graph-service-factory}.ts` //! diff --git a/crates/nostream-storage/src/backend.rs b/crates/nostream-storage/src/backend.rs index 3baa2ba4a..368b16c20 100644 --- a/crates/nostream-storage/src/backend.rs +++ b/crates/nostream-storage/src/backend.rs @@ -43,9 +43,20 @@ impl std::fmt::Display for Backend { /// An opened LMDB environment with one named database of string keys and values. /// -/// The constructor is the crate's single `unsafe` boundary: LMDB requires that an environment -/// is not opened twice in the same process (heed 0.20.5's `EnvOpenOptions::open` is an -/// `unsafe fn` for exactly that reason), and `LmdbEnv` guarantees one `Env` per path. +/// The constructor is the crate's single `unsafe` boundary. heed 0.20.5 marks +/// `EnvOpenOptions::open` unsafe because an LMDB environment memory-maps a file shared with +/// other processes and its guarantees collapse if the caller breaks the locking protocol. +/// heed itself upholds the in-process part: it canonicalizes the path, deduplicates through +/// a process-global registry, returns the *same* `Env` for a second open with equal options +/// and `Error::BadOpenOptions` when they differ (heed-0.20.5 `src/env.rs`, `OPENED_ENV`). +/// What this crate must therefore uphold — heed's documented preconditions, unchanged here: +/// +/// - never open an environment with `NO_LOCK` (concurrent access becomes the caller's job), +/// `NO_SYNC` or `NO_META_SYNC` (durability is silently dropped) — none is used; +/// - never point an environment at a network filesystem — the lock file semantics do not +/// survive it; the Phase-2 backend factory must refuse one; +/// - keep one `map_size` per path for the life of the process — a second open with a +/// different size surfaces as `Error::BadOpenOptions`, which `open` propagates. #[derive(Debug, Clone)] pub struct LmdbEnv { env: Env, @@ -76,8 +87,10 @@ impl LmdbEnv { source, })?; - // # Safety: LMDB forbids two environments over one data directory in the same - // process; this type owns the only `Env` and is never constructed elsewhere. + // # Safety: upheld by the preconditions on `LmdbEnv` above — no NO_LOCK/NO_SYNC + // flags, a local (non-network) path, and heed's own per-process dedup making a + // second open with the same options return the same environment rather than a + // second one. See heed-0.20.5's documented safety contract (`src/env.rs`). #[allow(unsafe_code)] let env = unsafe { EnvOpenOptions::new().map_size(map_size).open(&path) }.map_err(|source| { diff --git a/crates/nostream-storage/src/error.rs b/crates/nostream-storage/src/error.rs index 8b5efb8bb..4cdffedac 100644 --- a/crates/nostream-storage/src/error.rs +++ b/crates/nostream-storage/src/error.rs @@ -32,4 +32,9 @@ pub enum StorageError { #[source] source: std::io::Error, }, + + /// An inherited backend env var named a backend that does not exist + /// (`src/utils/event-store.ts` `Unsupported ${source} "${value}"`). + #[error("Unsupported {env} \"{value}\". Supported backends: postgres, lmdb")] + UnsupportedBackend { value: String, env: &'static str }, } diff --git a/crates/nostream-storage/src/event_store.rs b/crates/nostream-storage/src/event_store.rs index e1b4deb2e..1c17f7131 100644 --- a/crates/nostream-storage/src/event_store.rs +++ b/crates/nostream-storage/src/event_store.rs @@ -3,6 +3,8 @@ use std::env; +use crate::error::StorageError; + use nostream_config::Settings; /// The environment variable the primary sets on every worker it forks @@ -27,7 +29,7 @@ pub fn configured_backend(settings: &Settings) -> nostream_core::EventStoreBacke pub fn resolved_backend( settings: &Settings, is_worker: bool, -) -> Result { +) -> Result { let inherited = if is_worker { env::var(EVENT_STORE_BACKEND_ENV).ok() } else { @@ -35,11 +37,11 @@ pub fn resolved_backend( }; match inherited { None => Ok(configured_backend(settings)), - Some(name) => nostream_core::EventStoreBackend::parse(&name) - .ok_or_else(|| { - format!( - "Unsupported {EVENT_STORE_BACKEND_ENV} \"{name}\". Supported backends: postgres, lmdb" - ) - }), + Some(name) => nostream_core::EventStoreBackend::parse(&name).ok_or_else(|| { + StorageError::UnsupportedBackend { + value: name, + env: EVENT_STORE_BACKEND_ENV, + } + }), } } diff --git a/crates/nostream-storage/src/repositories.rs b/crates/nostream-storage/src/repositories.rs index e0a5ae98c..c47d0a949 100644 --- a/crates/nostream-storage/src/repositories.rs +++ b/crates/nostream-storage/src/repositories.rs @@ -8,6 +8,9 @@ use nostream_core::{EventId, Kind, Pubkey}; +use std::future::Future; +use std::pin::Pin; + use crate::error::StorageError; /// The rows a retention pass deleted, expired and kept (`src/@types/repositories.ts` @@ -35,7 +38,7 @@ pub trait EventRepository: Send + Sync { fn create( &self, _event: &nostream_core::Event, - ) -> impl Future> + Send; + ) -> Pin> + Send>>; /// Counts events matching the filters. /// @@ -43,7 +46,7 @@ pub trait EventRepository: Send + Sync { fn count_by_filters( &self, _filters: &[nostream_core::Filter], - ) -> impl Future> + Send; + ) -> Pin> + Send>>; /// Deletes events by pubkey and ids. /// @@ -52,7 +55,7 @@ pub trait EventRepository: Send + Sync { &self, _pubkey: &Pubkey, _ids: &[EventId], - ) -> impl Future> + Send; + ) -> Pin> + Send>>; /// Runs the retention pass. /// @@ -60,7 +63,7 @@ pub trait EventRepository: Send + Sync { fn delete_expired_and_retained( &self, _options: &EventRetentionOptions, - ) -> impl Future> + Send; + ) -> Pin> + Send>>; } /// Invoice storage (`src/@types/repositories.ts` `IInvoiceRepository`). @@ -71,12 +74,15 @@ pub trait InvoiceRepository: Send + Sync { fn find_by_id( &self, _id: &str, - ) -> impl Future, StorageError>> + Send; + ) -> Pin, StorageError>> + Send>>; /// Upserts an invoice, returning its row id. /// /// Phase 1 stub — job 005; will insert or update an invoice; see `src/repositories/invoice-repository.ts`. - fn upsert(&self, _invoice: &Invoice) -> impl Future> + Send; + fn upsert( + &self, + _invoice: &Invoice, + ) -> Pin> + Send>>; /// Loads pending invoices with pagination. /// @@ -85,7 +91,7 @@ pub trait InvoiceRepository: Send + Sync { &self, _offset: u64, _limit: u64, - ) -> impl Future, StorageError>> + Send; + ) -> Pin, StorageError>> + Send>>; } /// One payment invoice row (`src/@types/invoice.ts`). @@ -105,7 +111,7 @@ pub trait UserRepository: Send + Sync { fn find_by_pubkey( &self, _pubkey: &Pubkey, - ) -> impl Future, StorageError>> + Send; + ) -> Pin, StorageError>> + Send>>; /// Reads the balance of a pubkey. /// @@ -113,7 +119,7 @@ pub trait UserRepository: Send + Sync { fn get_balance_by_pubkey( &self, _pubkey: &Pubkey, - ) -> impl Future> + Send; + ) -> Pin> + Send>>; /// Marks a pubkey admitted. /// @@ -122,7 +128,7 @@ pub trait UserRepository: Send + Sync { &self, _pubkey: &Pubkey, _admitted_at: i64, - ) -> impl Future> + Send; + ) -> Pin> + Send>>; } /// One user row (`src/@types/user.ts`). @@ -141,7 +147,7 @@ pub trait Nip05VerificationRepository: Send + Sync { fn find_by_pubkey( &self, _pubkey: &Pubkey, - ) -> impl Future, StorageError>> + Send; + ) -> Pin, StorageError>> + Send>>; /// Upserts a verification record. /// @@ -149,7 +155,7 @@ pub trait Nip05VerificationRepository: Send + Sync { fn upsert( &self, _verification: &Nip05Verification, - ) -> impl Future> + Send; + ) -> Pin> + Send>>; } /// One NIP-05 verification row (`src/@types/nip05.ts`). @@ -170,7 +176,7 @@ pub trait InviteCodeRepository: Send + Sync { _code: &str, _max_uses: u32, _expiry_seconds: u64, - ) -> impl Future> + Send; + ) -> Pin> + Send>>; /// Claims a code for a pubkey, returning whether the claim succeeded. /// @@ -179,12 +185,14 @@ pub trait InviteCodeRepository: Send + Sync { &self, _code: &str, _pubkey: &Pubkey, - ) -> impl Future> + Send; + ) -> Pin> + Send>>; /// Deletes expired codes, returning the count. /// /// Phase 1 stub — job 005; will delete expired codes; see `src/repositories/invite-code-repository.ts`. - fn delete_expired_codes(&self) -> impl Future> + Send; + fn delete_expired_codes( + &self, + ) -> Pin> + Send>>; } /// One invite code (`src/@types/invite-code.ts`). @@ -205,7 +213,7 @@ pub trait DvmJobRepository: Send + Sync { _id: &EventId, _requester: &Pubkey, _kind: Kind, - ) -> impl Future> + Send; + ) -> Pin> + Send>>; /// Assigns a worker to a job. /// @@ -214,7 +222,7 @@ pub trait DvmJobRepository: Send + Sync { &self, _id: &EventId, _worker_index: u32, - ) -> impl Future> + Send; + ) -> Pin> + Send>>; /// Loads pending jobs, optionally by kind. /// @@ -223,7 +231,7 @@ pub trait DvmJobRepository: Send + Sync { &self, _limit: u64, _kinds: &[Kind], - ) -> impl Future, StorageError>> + Send; + ) -> Pin, StorageError>> + Send>>; } /// One DVM job row (`src/@types/dvm.ts`). @@ -241,8 +249,10 @@ pub trait ReportRepository: Send + Sync { /// Creates one report row. /// /// Phase 1 stub — job 005; will persist a kind-1984 report; see `src/repositories/report-repository.ts`. - fn create(&self, _report: &Report) - -> impl Future> + Send; + fn create( + &self, + _report: &Report, + ) -> Pin> + Send>>; /// Loads the reports against one event. /// @@ -250,7 +260,7 @@ pub trait ReportRepository: Send + Sync { fn find_by_event_id( &self, _event_id: &EventId, - ) -> impl Future, StorageError>> + Send; + ) -> Pin, StorageError>> + Send>>; /// Loads actionable reports, up to `limit`. /// @@ -258,7 +268,7 @@ pub trait ReportRepository: Send + Sync { fn find_actionable( &self, _limit: u64, - ) -> impl Future, StorageError>> + Send; + ) -> Pin, StorageError>> + Send>>; } /// One report row (`src/@types/report.ts`). @@ -280,7 +290,7 @@ pub trait NotificationDeliveryLogRepository: Send + Sync { fn append( &self, _entry: &DeliveryLogEntry, - ) -> impl Future> + Send; + ) -> Pin> + Send>>; /// Loads the recent log, newest first. /// @@ -288,7 +298,7 @@ pub trait NotificationDeliveryLogRepository: Send + Sync { fn find_recent( &self, _limit: u64, - ) -> impl Future, StorageError>> + Send; + ) -> Pin, StorageError>> + Send>>; /// Deletes entries older than the cutoff, returning the count. /// @@ -296,7 +306,7 @@ pub trait NotificationDeliveryLogRepository: Send + Sync { fn delete_older_than( &self, _cutoff: i64, - ) -> impl Future> + Send; + ) -> Pin> + Send>>; } /// One delivery attempt (`src/@types/operator-notifications.ts`). @@ -320,7 +330,7 @@ pub trait NotificationOutboxRepository: Send + Sync { &self, _event_type: &str, _payload: &str, - ) -> impl Future> + Send; + ) -> Pin> + Send>>; /// Claims a batch for delivery. /// @@ -328,12 +338,15 @@ pub trait NotificationOutboxRepository: Send + Sync { fn claim_batch( &self, _limit: u64, - ) -> impl Future, StorageError>> + Send; + ) -> Pin, StorageError>> + Send>>; /// Marks a message delivered. /// /// Phase 1 stub — job 005; will mark delivery success; see `src/repositories/notification-outbox-repository.ts`. - fn mark_delivered(&self, _id: &str) -> impl Future> + Send; + fn mark_delivered( + &self, + _id: &str, + ) -> Pin> + Send>>; } /// One outbox message (`src/@types/notification-outbox.ts`). diff --git a/crates/nostream-storage/tests/storage.rs b/crates/nostream-storage/tests/storage.rs index ec7a06860..db204c34c 100644 --- a/crates/nostream-storage/tests/storage.rs +++ b/crates/nostream-storage/tests/storage.rs @@ -35,3 +35,76 @@ async fn the_postgres_backend_is_a_labelled_stub() { ); assert!(matches!(error, StorageError::NotImplemented { .. })); } + +#[test] +fn the_repository_traits_are_dyn_compatible() { + // The seam exists so a backend can be picked at runtime: the trait objects must compile. + // A boxed stub proves the vtable exists without touching a database. + use std::future::Future; + use std::pin::Pin; + + use nostream_core::{Event, EventId, Pubkey}; + use nostream_storage::StorageError; + use nostream_storage::repositories::{ + EventPurgeCounts, EventRepository, Report, ReportRepository, + }; + + struct Noop; + + impl EventRepository for Noop { + fn create( + &self, + _event: &Event, + ) -> Pin> + Send>> { + Box::pin(async { Err(StorageError::NotImplemented { module: "noop" }) }) + } + + fn count_by_filters( + &self, + _filters: &[nostream_core::Filter], + ) -> Pin> + Send>> { + Box::pin(async { Err(StorageError::NotImplemented { module: "noop" }) }) + } + + fn delete_by_pubkey_and_ids( + &self, + _pubkey: &Pubkey, + _ids: &[EventId], + ) -> Pin> + Send>> { + Box::pin(async { Err(StorageError::NotImplemented { module: "noop" }) }) + } + + fn delete_expired_and_retained( + &self, + _options: &nostream_storage::repositories::EventRetentionOptions, + ) -> Pin> + Send>> { + Box::pin(async { Err(StorageError::NotImplemented { module: "noop" }) }) + } + } + + impl ReportRepository for Noop { + fn create( + &self, + _report: &Report, + ) -> Pin> + Send>> { + Box::pin(async { Err(StorageError::NotImplemented { module: "noop" }) }) + } + + fn find_by_event_id( + &self, + _event_id: &EventId, + ) -> Pin, StorageError>> + Send>> { + Box::pin(async { Err(StorageError::NotImplemented { module: "noop" }) }) + } + + fn find_actionable( + &self, + _limit: u64, + ) -> Pin, StorageError>> + Send>> { + Box::pin(async { Err(StorageError::NotImplemented { module: "noop" }) }) + } + } + + let _event_repository: std::sync::Arc = std::sync::Arc::new(Noop); + let _report_repository: Box = Box::new(Noop); +} diff --git a/scripts/rust.sh b/scripts/rust.sh index 0d0316826..45263fa8c 100755 --- a/scripts/rust.sh +++ b/scripts/rust.sh @@ -43,8 +43,11 @@ if [ -n "${NOSTREAM_HOST_DIR:-}" ]; then else checked_root=$local_root fi -if [ ! -f "$checked_root/Cargo.toml" ]; then - echo "scripts/rust.sh: no Cargo.toml at $checked_root (set NOSTREAM_HOST_DIR)" >&2 +# The host path is invisible from inside the sandbox, so an explicit NOSTREAM_HOST_DIR does +# not prove anything here; fall back to the local tree (the bind source the script itself +# hands to the daemon) before refusing. +if [ ! -f "$checked_root/Cargo.toml" ] && [ ! -f "$local_root/Cargo.toml" ]; then + echo "scripts/rust.sh: no Cargo.toml at $checked_root or $local_root (set NOSTREAM_HOST_DIR)" >&2 exit 66 fi From e81b331b0f9dd820ed0dcafe29f4e2c9289244b2 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 16:22:50 -0400 Subject: [PATCH 16/32] fix(daemon,core): serve on the defaults, wire --log-level, surface worker errors, keep the drain flag The settings gate moves to a library fn and follows the capture's fallback rule: findings are logged and the serve path continues on the shipped defaults; --check alone exits 2 (P1). --log-level now builds the filter (cli > LOG_LEVEL > RUST_LOG > info) and is passed into telemetry::init (P2). Worker and tor task failures are warn-logged at drain instead of being discarded (P2). ShutdownState::begin_draining uses send_replace so a drain before the first subscriber is stored, not silently dropped, with a regression test on both orders (P2). --- crates/nostream-core/src/lifecycle.rs | 7 +++- crates/nostream-core/tests/lifecycle.rs | 27 +++++++++++++ crates/nostreamd/src/lib.rs | 1 + crates/nostreamd/src/main.rs | 46 ++++++++++++++--------- crates/nostreamd/src/settings_gate.rs | 34 +++++++++++++++++ crates/nostreamd/src/telemetry.rs | 21 ++++++++++- crates/nostreamd/tests/settings_gate.rs | 50 +++++++++++++++++++++++++ 7 files changed, 165 insertions(+), 21 deletions(-) create mode 100644 crates/nostream-core/tests/lifecycle.rs create mode 100644 crates/nostreamd/src/settings_gate.rs create mode 100644 crates/nostreamd/tests/settings_gate.rs diff --git a/crates/nostream-core/src/lifecycle.rs b/crates/nostream-core/src/lifecycle.rs index d8d038656..c611c34b6 100644 --- a/crates/nostream-core/src/lifecycle.rs +++ b/crates/nostream-core/src/lifecycle.rs @@ -26,8 +26,13 @@ impl ShutdownState { } /// Marks the process as draining. Safe to call more than once. + /// + /// `send_replace` rather than `send`: the value must be stored even when no receiver is + /// subscribed yet (`Sender::send` short-circuits and drops the value at + /// `receiver_count() == 0`), or a drain that starts before the first subscriber would + /// be lost and a later `wait_for_drain` would hang. pub fn begin_draining(&self) { - let _ = self.sender.send(true); + self.sender.send_replace(true); } /// Whether draining has begun. diff --git a/crates/nostream-core/tests/lifecycle.rs b/crates/nostream-core/tests/lifecycle.rs new file mode 100644 index 000000000..b639725a7 --- /dev/null +++ b/crates/nostream-core/tests/lifecycle.rs @@ -0,0 +1,27 @@ +//! The drain flag must survive a drain that starts before any subscriber exists. + +use nostream_core::lifecycle::ShutdownState; + +#[tokio::test] +async fn begin_draining_before_any_subscriber_is_not_lost() { + let state = ShutdownState::new(); + // No watch() has been called: receiver_count() is 0, the case where Sender::send + // short-circuits. + state.begin_draining(); + assert!(state.is_draining()); + tokio::time::timeout(std::time::Duration::from_secs(1), state.wait_for_drain()) + .await + .expect("wait_for_drain resolves") +} + +#[tokio::test] +async fn wait_for_drain_after_a_subscriber_arrives() { + let state = ShutdownState::new(); + let watcher = state.clone(); + let waiter = tokio::spawn(async move { watcher.wait_for_drain().await }); + state.begin_draining(); + tokio::time::timeout(std::time::Duration::from_secs(1), waiter) + .await + .expect("the waiter resolves") + .expect("join"); +} diff --git a/crates/nostreamd/src/lib.rs b/crates/nostreamd/src/lib.rs index d1b813741..06bd499b2 100644 --- a/crates/nostreamd/src/lib.rs +++ b/crates/nostreamd/src/lib.rs @@ -18,6 +18,7 @@ pub mod acceptor; pub mod error; +pub mod settings_gate; pub mod telemetry; pub mod workers; diff --git a/crates/nostreamd/src/main.rs b/crates/nostreamd/src/main.rs index 50c7fabb0..86821e8fd 100644 --- a/crates/nostreamd/src/main.rs +++ b/crates/nostreamd/src/main.rs @@ -10,7 +10,7 @@ use std::process::ExitCode; use clap::Parser; use nostream_config::SettingsPaths; use nostream_core::lifecycle::ShutdownState; -use nostreamd::{DaemonError, telemetry}; +use nostreamd::{DaemonError, settings_gate, telemetry}; #[derive(Debug, Parser)] #[command(name = "nostreamd", about = "The nostream relay daemon")] @@ -61,21 +61,13 @@ fn run(cli: Cli) -> Result { let outcome = nostream_config::load(&paths).map_err(|error| DaemonError::Setup(error.to_string()))?; - if !outcome.issues.is_empty() { - for issue in &outcome.issues { - eprintln!("{issue}"); - } - return Err(DaemonError::InvalidSettings { - count: outcome.issues.len(), - }); - } - - if cli.check { - println!("settings valid: 0 issues"); - return Ok(ExitCode::SUCCESS); - } + settings_gate::apply_settings_gate(&outcome, cli.check)?; - telemetry::init(&outcome.settings).map_err(|error| DaemonError::Setup(error.to_string()))?; + telemetry::init( + &outcome.settings, + telemetry::resolve_filter(cli.log_level.as_deref()), + ) + .map_err(|error| DaemonError::Setup(error.to_string()))?; let port = cli .listen_port @@ -115,9 +107,27 @@ async fn serve(listen: SocketAddr) -> Result { tracing::info!("signal received; draining"); shutdown.begin_draining(); - let _ = accept_task.await; - let _ = workers_task.await; - let _ = tor_task.await; + let accept_result = accept_task + .await + .map_err(|error| DaemonError::Setup(format!("acceptor task: {error}"))) + .and_then(std::convert::identity); + let workers_result = workers_task + .await + .map_err(|error| DaemonError::Setup(format!("workers task: {error}"))) + .and_then(std::convert::identity); + let tor_result = tor_task + .await + .map_err(|error| DaemonError::Setup(format!("tor task: {error}"))) + .and_then(std::convert::identity); + for (name, result) in [ + ("acceptor", accept_result), + ("workers", workers_result), + ("tor", tor_result), + ] { + if let Err(error) = result { + tracing::warn!(task = name, error = %error, "background task ended with an error"); + } + } tracing::info!("drained; exiting"); Ok(ExitCode::SUCCESS) diff --git a/crates/nostreamd/src/settings_gate.rs b/crates/nostreamd/src/settings_gate.rs new file mode 100644 index 000000000..1583e56cf --- /dev/null +++ b/crates/nostreamd/src/settings_gate.rs @@ -0,0 +1,34 @@ +//! The daemon's settings gate: the capture's fallback rule, applied once. +//! +//! A user configuration that cannot be read or does not validate never aborts the process: +//! `nostream_config::load` substitutes the shipped defaults and reports the findings. The +//! serve path logs every finding and continues on the defaults; `--check` is the one place +//! findings are fatal (exit 2), because its whole job is to report them. + +use nostream_config::LoadOutcome; + +use crate::error::DaemonError; + +/// Applies the gate to one load outcome: report the findings, decide whether they are fatal. +pub fn apply_settings_gate(outcome: &LoadOutcome, check: bool) -> Result<(), DaemonError> { + if outcome.issues.is_empty() { + if check { + println!("settings valid: 0 issues"); + } + return Ok(()); + } + + for issue in &outcome.issues { + eprintln!("{issue}"); + } + if check { + return Err(DaemonError::InvalidSettings { + count: outcome.issues.len(), + }); + } + eprintln!( + "nostreamd: continuing on the shipped defaults ({} issue(s) in the user settings)", + outcome.issues.len() + ); + Ok(()) +} diff --git a/crates/nostreamd/src/telemetry.rs b/crates/nostreamd/src/telemetry.rs index 1eeb1d446..49123f438 100644 --- a/crates/nostreamd/src/telemetry.rs +++ b/crates/nostreamd/src/telemetry.rs @@ -3,14 +3,31 @@ //! exist — the deviation is recorded in `docs/rewrite/decisions/0001-runtime-and-telemetry.md`). use nostream_config::Settings; +use tracing_subscriber::EnvFilter; use crate::error::DaemonError; +/// Resolves the log filter: `--log-level` first, then `LOG_LEVEL`, then `RUST_LOG`, else +/// INFO. `EnvFilter::from_default_env` alone would read only `RUST_LOG` and default to +/// ERROR, which silences the daemon's own startup lines. +pub fn resolve_filter(cli_level: Option<&str>) -> EnvFilter { + if let Some(level) = cli_level { + return EnvFilter::try_new(level).unwrap_or_else(|_| EnvFilter::new("info")); + } + if let Some(filter) = std::env::var_os("LOG_LEVEL") + .and_then(|value| value.into_string().ok()) + .and_then(|level| EnvFilter::try_new(level).ok()) + { + return filter; + } + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")) +} + /// Installs the global subscriber from the settings. /// /// The OTLP endpoint comes from `OTEL_EXPORTER_OTLP_ENDPOINT` (the OTEL SDK convention), not /// from a settings field: the capture's settings carry no telemetry section. -pub fn init(settings: &Settings) -> Result<(), DaemonError> { +pub fn init(settings: &Settings, filter: EnvFilter) -> Result<(), DaemonError> { let _ = settings; #[cfg(feature = "otlp")] { @@ -21,7 +38,7 @@ pub fn init(settings: &Settings) -> Result<(), DaemonError> { } } tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .with_env_filter(filter) .with_target(false) .try_init() .map_err(|error| DaemonError::Setup(format!("telemetry: {error}"))) diff --git a/crates/nostreamd/tests/settings_gate.rs b/crates/nostreamd/tests/settings_gate.rs new file mode 100644 index 000000000..644047469 --- /dev/null +++ b/crates/nostreamd/tests/settings_gate.rs @@ -0,0 +1,50 @@ +//! A malformed user settings file must serve on the defaults, not abort (R7's fallback rule). + +use std::fs; +use std::path::PathBuf; + +use nostream_config::SettingsPaths; + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../") + .canonicalize() + .expect("the workspace root exists") +} + +#[test] +fn a_malformed_user_file_loads_the_defaults_with_findings() { + let directory = tempfile::tempdir().expect("tempdir"); + fs::write( + directory.path().join("settings.yaml"), + "limits:\n rateLimiter:\n strategy: random\n", + ) + .expect("write"); + + let paths = SettingsPaths::from_config_dir(&workspace_root(), directory.path()); + let outcome = nostream_config::load(&paths).expect("the loader never refuses"); + + assert!(outcome.used_defaults, "the defaults substitute"); + assert!(!outcome.issues.is_empty(), "the findings are reported"); + + // The gate on the serve path (check=false): findings logged, no error. + nostreamd::settings_gate::apply_settings_gate(&outcome, false).expect("serve continues"); + + // The gate on the check path (check=true): findings are fatal. + let error = nostreamd::settings_gate::apply_settings_gate(&outcome, true) + .expect_err("check refuses an invalid document"); + assert!(matches!( + error, + nostreamd::DaemonError::InvalidSettings { count: 1 } + )); +} + +#[test] +fn a_clean_document_passes_the_gate_both_ways() { + let directory = tempfile::tempdir().expect("tempdir"); // empty config dir: no overrides + let paths = SettingsPaths::from_config_dir(&workspace_root(), directory.path()); + let outcome = nostream_config::load(&paths).expect("load"); + assert!(!outcome.used_defaults); + assert!(outcome.issues.is_empty()); + nostreamd::settings_gate::apply_settings_gate(&outcome, true).expect("check passes"); +} From 60b3560bc240344179f12d147cae1b23bf9d7c5a Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 16:26:28 -0400 Subject: [PATCH 17/32] fix(admin,daemon,ci): the full admin surface, corrected ADR premise, SHA-pinned actions Admin: the route table carries all 18 entries in capture order plus the two prefix middlewares and the /assets mount; the missing 11 controller modules and 4 request-handler modules exist as typed stubs (30 modules total); get_dashboard cites the handler's real path; the lib header states the full inventory. Daemon: the module map is spec 3.2's daemon list (src/app/, telemetry/, routes/index.ts, logger.ts, tor, web-server-adapter, the 8 factories) with the WS adapters returned to nostream-relay. ADR 0001: the capture's process model is corrected to cluster-based (App primary forks cpus().length client workers that all listen(); IPC into each adapter) and D8.2 states Phase 2 decides against that model. CI: the phantom toolchain-file input is dropped (the pin rides the action's auto-read), both third-party actions are SHA-pinned with measured SHAs, and the job declares permissions contents:read. --- .github/workflows/checks.yml | 11 ++- .../controllers/admin_enabled_middleware.rs | 10 +++ .../src/controllers/get_dashboard.rs | 5 +- .../src/controllers/get_metrics.rs | 10 +++ .../src/controllers/get_network_health.rs | 10 +++ .../get_notification_delivery_log.rs | 10 +++ .../src/controllers/get_notifications.rs | 10 +++ .../src/controllers/get_session.rs | 10 +++ .../src/controllers/get_settings_backups.rs | 10 +++ .../src/controllers/get_settings_schema.rs | 10 +++ crates/nostream-admin/src/controllers/mod.rs | 19 ++++- .../src/controllers/patch_notifications.rs | 10 +++ .../src/controllers/post_logout.rs | 10 +++ .../src/controllers/post_notification_test.rs | 10 +++ .../src/controllers/post_settings_restore.rs | 10 +++ .../controllers/rate_limiter_middleware.rs | 10 +++ .../with_admin_controller_request_handler.rs | 10 +++ .../with_controller_request_handler.rs | 10 +++ crates/nostream-admin/src/lib.rs | 7 +- crates/nostream-admin/src/routes/mod.rs | 82 +++++++++++++++++-- crates/nostreamd/src/acceptor.rs | 5 +- crates/nostreamd/src/lib.rs | 26 ++++-- .../decisions/0001-runtime-and-telemetry.md | 28 +++++-- 23 files changed, 298 insertions(+), 35 deletions(-) create mode 100644 crates/nostream-admin/src/controllers/admin_enabled_middleware.rs create mode 100644 crates/nostream-admin/src/controllers/get_metrics.rs create mode 100644 crates/nostream-admin/src/controllers/get_network_health.rs create mode 100644 crates/nostream-admin/src/controllers/get_notification_delivery_log.rs create mode 100644 crates/nostream-admin/src/controllers/get_notifications.rs create mode 100644 crates/nostream-admin/src/controllers/get_session.rs create mode 100644 crates/nostream-admin/src/controllers/get_settings_backups.rs create mode 100644 crates/nostream-admin/src/controllers/get_settings_schema.rs create mode 100644 crates/nostream-admin/src/controllers/patch_notifications.rs create mode 100644 crates/nostream-admin/src/controllers/post_logout.rs create mode 100644 crates/nostream-admin/src/controllers/post_notification_test.rs create mode 100644 crates/nostream-admin/src/controllers/post_settings_restore.rs create mode 100644 crates/nostream-admin/src/controllers/rate_limiter_middleware.rs create mode 100644 crates/nostream-admin/src/controllers/with_admin_controller_request_handler.rs create mode 100644 crates/nostream-admin/src/controllers/with_controller_request_handler.rs diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 19c32ce90..5114094ac 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -223,15 +223,18 @@ jobs: runs-on: ubuntu-latest needs: changes if: needs.changes.outputs.rust == 'true' + permissions: + contents: read steps: - name: Checkout uses: actions/checkout@v4 + # rust-toolchain.toml is read automatically by the action's discovery (no toolchain-file + # input exists; passing one only warns). Both actions are SHA-pinned per the house + # convention the docker/* actions below already follow. - name: Install Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: - toolchain-file: rust-toolchain.toml + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 - name: Cache cargo - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - name: Check formatting run: cargo fmt --all --check - name: Clippy (all targets) diff --git a/crates/nostream-admin/src/controllers/admin_enabled_middleware.rs b/crates/nostream-admin/src/controllers/admin_enabled_middleware.rs new file mode 100644 index 000000000..97b65559e --- /dev/null +++ b/crates/nostream-admin/src/controllers/admin_enabled_middleware.rs @@ -0,0 +1,10 @@ +//! `admin-enabled-middleware.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will gates the admin surface on the feature flag; see `src/handlers/request-handlers/admin-enabled-middleware.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::admin_enabled_middleware", + }) +} diff --git a/crates/nostream-admin/src/controllers/get_dashboard.rs b/crates/nostream-admin/src/controllers/get_dashboard.rs index 1aa3e3813..78dbaf876 100644 --- a/crates/nostream-admin/src/controllers/get_dashboard.rs +++ b/crates/nostream-admin/src/controllers/get_dashboard.rs @@ -1,8 +1,9 @@ -//! `get-admin-dashboard-controller.ts`. +//! `get-admin-dashboard-request-handler.ts`. use crate::error::AdminError; -/// Phase 1 stub — job 005; will renders the admin dashboard payload; see `src/controllers/admin/get-admin-dashboard-controller.ts` or `src/handlers/request-handlers/get-admin-dashboard-controller.ts`. +/// Phase 1 stub — job 005; will render the admin dashboard; see +/// `src/handlers/request-handlers/get-admin-dashboard-request-handler.ts`. pub async fn handle() -> Result<(), AdminError> { Err(AdminError::NotImplemented { module: "admin::controllers::get_dashboard", diff --git a/crates/nostream-admin/src/controllers/get_metrics.rs b/crates/nostream-admin/src/controllers/get_metrics.rs new file mode 100644 index 000000000..2992dc930 --- /dev/null +++ b/crates/nostream-admin/src/controllers/get_metrics.rs @@ -0,0 +1,10 @@ +//! `get-metrics-controller.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will renders the Prometheus metrics payload; see `src/controllers/admin/get-metrics-controller.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::get_metrics", + }) +} diff --git a/crates/nostream-admin/src/controllers/get_network_health.rs b/crates/nostream-admin/src/controllers/get_network_health.rs new file mode 100644 index 000000000..5b27738b7 --- /dev/null +++ b/crates/nostream-admin/src/controllers/get_network_health.rs @@ -0,0 +1,10 @@ +//! `get-network-health-controller.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will reports the network-health comparison; see `src/controllers/admin/get-network-health-controller.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::get_network_health", + }) +} diff --git a/crates/nostream-admin/src/controllers/get_notification_delivery_log.rs b/crates/nostream-admin/src/controllers/get_notification_delivery_log.rs new file mode 100644 index 000000000..a681ec9b9 --- /dev/null +++ b/crates/nostream-admin/src/controllers/get_notification_delivery_log.rs @@ -0,0 +1,10 @@ +//! `get-notification-delivery-log-controller.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will lists the notification delivery log; see `src/controllers/admin/get-notification-delivery-log-controller.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::get_notification_delivery_log", + }) +} diff --git a/crates/nostream-admin/src/controllers/get_notifications.rs b/crates/nostream-admin/src/controllers/get_notifications.rs new file mode 100644 index 000000000..43f0fb5e5 --- /dev/null +++ b/crates/nostream-admin/src/controllers/get_notifications.rs @@ -0,0 +1,10 @@ +//! `get-notifications-controller.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will reads the operator notification settings; see `src/controllers/admin/get-notifications-controller.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::get_notifications", + }) +} diff --git a/crates/nostream-admin/src/controllers/get_session.rs b/crates/nostream-admin/src/controllers/get_session.rs new file mode 100644 index 000000000..dbdd3b263 --- /dev/null +++ b/crates/nostream-admin/src/controllers/get_session.rs @@ -0,0 +1,10 @@ +//! `get-session-controller.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will returns the current admin session; see `src/controllers/admin/get-session-controller.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::get_session", + }) +} diff --git a/crates/nostream-admin/src/controllers/get_settings_backups.rs b/crates/nostream-admin/src/controllers/get_settings_backups.rs new file mode 100644 index 000000000..6775afdf2 --- /dev/null +++ b/crates/nostream-admin/src/controllers/get_settings_backups.rs @@ -0,0 +1,10 @@ +//! `get-settings-backups-controller.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will lists the settings backups; see `src/controllers/admin/get-settings-backups-controller.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::get_settings_backups", + }) +} diff --git a/crates/nostream-admin/src/controllers/get_settings_schema.rs b/crates/nostream-admin/src/controllers/get_settings_schema.rs new file mode 100644 index 000000000..9b8b73582 --- /dev/null +++ b/crates/nostream-admin/src/controllers/get_settings_schema.rs @@ -0,0 +1,10 @@ +//! `get-settings-schema-controller.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will serves the guided settings schema; see `src/controllers/admin/get-settings-schema-controller.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::get_settings_schema", + }) +} diff --git a/crates/nostream-admin/src/controllers/mod.rs b/crates/nostream-admin/src/controllers/mod.rs index 54684bfee..eccf4149d 100644 --- a/crates/nostream-admin/src/controllers/mod.rs +++ b/crates/nostream-admin/src/controllers/mod.rs @@ -1,17 +1,32 @@ -//! The admin controllers and request handlers. Mirrors `src/controllers/admin/` and -//! `src/handlers/request-handlers/` at the capture commit. +//! The admin controllers and request handlers. Mirrors `src/controllers/admin/` (16 files) +//! and `src/handlers/request-handlers/` (14 files) at the capture commit. pub mod admin_auth_middleware; +pub mod admin_enabled_middleware; pub mod admin_json_body_middleware; pub mod admin_rate_limit_middleware; pub mod get_dashboard; pub mod get_health; +pub mod get_metrics; +pub mod get_network_health; +pub mod get_notification_delivery_log; +pub mod get_notifications; pub mod get_privacy; pub mod get_readyz; +pub mod get_session; pub mod get_settings; +pub mod get_settings_backups; +pub mod get_settings_schema; pub mod get_terms; pub mod nodeinfo; +pub mod patch_notifications; pub mod patch_settings; pub mod post_login; +pub mod post_logout; +pub mod post_notification_test; +pub mod post_settings_restore; pub mod post_settings_validate; +pub mod rate_limiter_middleware; pub mod root; +pub mod with_admin_controller_request_handler; +pub mod with_controller_request_handler; diff --git a/crates/nostream-admin/src/controllers/patch_notifications.rs b/crates/nostream-admin/src/controllers/patch_notifications.rs new file mode 100644 index 000000000..071f17c4a --- /dev/null +++ b/crates/nostream-admin/src/controllers/patch_notifications.rs @@ -0,0 +1,10 @@ +//! `patch-notifications-controller.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will applies a notification settings patch; see `src/controllers/admin/patch-notifications-controller.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::patch_notifications", + }) +} diff --git a/crates/nostream-admin/src/controllers/post_logout.rs b/crates/nostream-admin/src/controllers/post_logout.rs new file mode 100644 index 000000000..c7305cf88 --- /dev/null +++ b/crates/nostream-admin/src/controllers/post_logout.rs @@ -0,0 +1,10 @@ +//! `post-logout-controller.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will ends the admin session; see `src/controllers/admin/post-logout-controller.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::post_logout", + }) +} diff --git a/crates/nostream-admin/src/controllers/post_notification_test.rs b/crates/nostream-admin/src/controllers/post_notification_test.rs new file mode 100644 index 000000000..f97d4bb73 --- /dev/null +++ b/crates/nostream-admin/src/controllers/post_notification_test.rs @@ -0,0 +1,10 @@ +//! `post-notification-test-controller.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will sends one test notification; see `src/controllers/admin/post-notification-test-controller.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::post_notification_test", + }) +} diff --git a/crates/nostream-admin/src/controllers/post_settings_restore.rs b/crates/nostream-admin/src/controllers/post_settings_restore.rs new file mode 100644 index 000000000..840cf8840 --- /dev/null +++ b/crates/nostream-admin/src/controllers/post_settings_restore.rs @@ -0,0 +1,10 @@ +//! `post-settings-restore-controller.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will restores a settings backup; see `src/controllers/admin/post-settings-restore-controller.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::post_settings_restore", + }) +} diff --git a/crates/nostream-admin/src/controllers/rate_limiter_middleware.rs b/crates/nostream-admin/src/controllers/rate_limiter_middleware.rs new file mode 100644 index 000000000..0c35cfa35 --- /dev/null +++ b/crates/nostream-admin/src/controllers/rate_limiter_middleware.rs @@ -0,0 +1,10 @@ +//! `rate-limiter-middleware.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will applies the Redis-backed sliding window; see `src/handlers/request-handlers/rate-limiter-middleware.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::rate_limiter_middleware", + }) +} diff --git a/crates/nostream-admin/src/controllers/with_admin_controller_request_handler.rs b/crates/nostream-admin/src/controllers/with_admin_controller_request_handler.rs new file mode 100644 index 000000000..5bfe08589 --- /dev/null +++ b/crates/nostream-admin/src/controllers/with_admin_controller_request_handler.rs @@ -0,0 +1,10 @@ +//! `with-admin-controller-request-handler.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will wraps an admin controller with the shared request plumbing; see `src/handlers/request-handlers/with-admin-controller-request-handler.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::with_admin_controller_request_handler", + }) +} diff --git a/crates/nostream-admin/src/controllers/with_controller_request_handler.rs b/crates/nostream-admin/src/controllers/with_controller_request_handler.rs new file mode 100644 index 000000000..cf83ba703 --- /dev/null +++ b/crates/nostream-admin/src/controllers/with_controller_request_handler.rs @@ -0,0 +1,10 @@ +//! `with-controller-request-handler.ts`. + +use crate::error::AdminError; + +/// Phase 1 stub — job 005; will wraps a controller with the shared request plumbing; see `src/handlers/request-handlers/with-controller-request-handler.ts`. +pub async fn handle() -> Result<(), AdminError> { + Err(AdminError::NotImplemented { + module: "admin::controllers::with_controller_request_handler", + }) +} diff --git a/crates/nostream-admin/src/lib.rs b/crates/nostream-admin/src/lib.rs index 59ff73779..019196043 100644 --- a/crates/nostream-admin/src/lib.rs +++ b/crates/nostream-admin/src/lib.rs @@ -4,8 +4,11 @@ //! It owns these TypeScript paths from the capture commit //! (`242a3001d77149e4d70151ec03060c4ef44a1547`): //! -//! - `src/handlers/request-handlers/` (14 files, HTTP rather than Nostr wire) -//! - `src/controllers/admin/` (16) and `src/routes/admin/index.ts` +//! - `src/handlers/request-handlers/` (all 14 files — the eight HTTP document/health +//! handlers and the six middlewares/wrappers, including `admin-enabled-middleware`, +//! `rate-limiter-middleware`, `with-admin-controller-request-handler` and +//! `with-controller-request-handler`) +//! - `src/controllers/admin/` (all 16) and `src/routes/admin/index.ts` (all 18 routes) //! - `src/schemas/admin-*.ts` (3) //! - `src/utils/admin-*.ts` (7 files, except `admin-notifications-settings.ts`, which belongs //! to `nostream-config`) diff --git a/crates/nostream-admin/src/routes/mod.rs b/crates/nostream-admin/src/routes/mod.rs index 5cdfb9dd0..5421759de 100644 --- a/crates/nostream-admin/src/routes/mod.rs +++ b/crates/nostream-admin/src/routes/mod.rs @@ -1,4 +1,6 @@ -//! The admin route table. Mirrors `src/routes/admin/index.ts`. +//! The admin route table. Mirrors `src/routes/admin/index.ts` at the capture: all eighteen +//! registered admin routes, in registration order, plus the two `router.use` middlewares and +//! the `/assets` static mount that precede them. //! //! No `axum` in Phase 1: the routes are a typed table, not mounted handlers. @@ -11,20 +13,88 @@ pub struct AdminRoute { pub path: &'static str, } -/// The table, in the order the capture registers them. -pub const ADMIN_ROUTES: [AdminRoute; 3] = [ +/// The table, in the order the capture registers them (`src/routes/admin/index.ts`). +pub const ADMIN_ROUTES: [AdminRoute; 18] = [ + AdminRoute { + method: "GET", + path: "/", + }, + AdminRoute { + method: "GET", + path: "/dashboard", + }, AdminRoute { method: "POST", - path: "/admin/login", + path: "/login", + }, + AdminRoute { + method: "POST", + path: "/logout", + }, + AdminRoute { + method: "GET", + path: "/session", + }, + AdminRoute { + method: "GET", + path: "/health", + }, + AdminRoute { + method: "GET", + path: "/metrics", + }, + AdminRoute { + method: "GET", + path: "/network-health", + }, + AdminRoute { + method: "GET", + path: "/settings", }, AdminRoute { method: "GET", - path: "/admin/settings", + path: "/settings/backups", + }, + AdminRoute { + method: "GET", + path: "/settings/schema", }, AdminRoute { method: "PATCH", - path: "/admin/settings", + path: "/settings", + }, + AdminRoute { + method: "POST", + path: "/settings/validate", + }, + AdminRoute { + method: "POST", + path: "/settings/restore", + }, + AdminRoute { + method: "GET", + path: "/notifications", }, + AdminRoute { + method: "PATCH", + path: "/notifications", + }, + AdminRoute { + method: "GET", + path: "/notifications/deliveries", + }, + AdminRoute { + method: "POST", + path: "/notifications/test", + }, +]; + +/// The two `router.use` middlewares and the static mount that precede the table +/// (`rateLimiterMiddleware`, `adminEnabledMiddleware`, `/assets` static files). +pub const PREFIX_MIDDLEWARE: [&str; 3] = [ + "rateLimiterMiddleware", + "adminEnabledMiddleware", + "/assets (static)", ]; /// Mounts the table onto a Phase-2 HTTP router. diff --git a/crates/nostreamd/src/acceptor.rs b/crates/nostreamd/src/acceptor.rs index 8939d6881..01b7b66f5 100644 --- a/crates/nostreamd/src/acceptor.rs +++ b/crates/nostreamd/src/acceptor.rs @@ -1,8 +1,9 @@ //! The acceptor: one task owning the listener, accepting sockets, handing each to the relay. //! //! This is the Phase-1 runtime decision (ADR 0001): a **single acceptor task**, no -//! `SO_REUSEPORT`, no per-core acceptor sharding — the TS relay is a single Node process, and -//! the multi-worker fan-out is a Phase-2 decision to take against measurements, not upfront. +//! `SO_REUSEPORT`, no per-core acceptor sharding. The capture's model is a cluster primary +//! forking `cpus().length` client workers that all `listen()` the same port plus an IPC hop; +//! Phase 2 decides against or reproduces that fan-out on measurements. use std::net::SocketAddr; diff --git a/crates/nostreamd/src/lib.rs b/crates/nostreamd/src/lib.rs index 06bd499b2..c03bc850e 100644 --- a/crates/nostreamd/src/lib.rs +++ b/crates/nostreamd/src/lib.rs @@ -1,17 +1,27 @@ //! `nostreamd` — the relay daemon: the runtime, the acceptor, telemetry and the signals. //! //! It owns these TypeScript paths from the capture commit -//! (`242a3001d77149e4d70151ec03060c4ef44a1547`): +//! (`242a3001d77149e4d70151ec03060c4ef44a1547`) — spec §3.2's daemon list: //! -//! - `src/index.ts` and `src/main.ts` (bootstrap, signal handling, worker scheduling) -//! - `src/adapters/{web-socket-server-adapter,web-socket-adapter}.ts` (the listening half) -//! - `src/utils/scheduler.ts` (the recurring-worker loop) -//! - `src/tor/**` (the hidden service bootstrap) +//! - `src/app/` (6 files: `app.ts` — the cluster primary that forks the workers — plus +//! `worker.ts` and the four specialist workers) +//! - `src/index.ts` (the bootstrap) and `src/logger.ts` +//! - `src/adapters/web-server-adapter.ts` (the listening half; the two WebSocket adapters +//! belong to `nostream-relay`) +//! - `src/routes/index.ts` (the top-level route mount) +//! - `src/telemetry/{metrics,event-metrics}.ts` +//! - `src/tor/client.ts` and `src/@types/tor.ts` +//! - the remaining `src/factories/` wiring: `app-factory`, `web-app-factory`, +//! `worker-factory`, `logger-factory`, `maintenance-worker-factory`, +//! `relay-monitor-worker-factory`, `static-mirroring.worker-factory`, +//! `dvm-orchestrator-worker-factory` //! //! Runtime decision (ADR `0001-runtime-and-telemetry.md`): a single acceptor task, no -//! `SO_REUSEPORT` sharding until measurements ask for it. Phase 1 binds, accepts, drains on -//! SIGINT/SIGTERM through `nostream_core::lifecycle::ShutdownState` and drops sockets with a -//! debug line; the relay wire takes the socket in Phase 2. +//! `SO_REUSEPORT` sharding — the capture's cluster fan-out (primary + `cpus().length` +//! client workers all listening, IPC into each adapter) is the model Phase 2 decides +//! against. Phase 1 binds, accepts, drains on SIGINT/SIGTERM through +//! `nostream_core::lifecycle::ShutdownState` and drops sockets with a debug line; the relay +//! wire takes the socket in Phase 2. //! //! The MCP server named in the issue is Phase 5 and has no TS counterpart at the capture; it //! has no module here either. diff --git a/docs/rewrite/decisions/0001-runtime-and-telemetry.md b/docs/rewrite/decisions/0001-runtime-and-telemetry.md index f110c8865..ac9cb5058 100644 --- a/docs/rewrite/decisions/0001-runtime-and-telemetry.md +++ b/docs/rewrite/decisions/0001-runtime-and-telemetry.md @@ -6,11 +6,17 @@ ## Context -The Rust rewrite replaces a Node.js relay that runs one process per worker -(`cluster`-less: `src/index.ts` boots an `AppWorker` directly), listens on -`PORT || RELAY_PORT || 8008` (`src/app/worker.ts:32`), and drains on SIGINT/SIGTERM -through `src/utils/shutdown-state.ts`. The telemetry section of the original spec -named a `tracing-otlp` crate. +The Rust rewrite replaces a Node.js relay whose process model is **cluster-based**: +`src/index.ts` boots `App` in the primary, `src/app/app.ts:102-125` forks +`cpus().length` client workers (`WORKER_TYPE=worker`) plus dedicated +maintenance / static-mirroring / relay-monitor / DVM workers, and **every worker calls +`adapter.listen(port)`** (`src/app/worker.ts:32`) — the OS queues the connections and each +worker accepts, with cluster IPC re-emitting messages into the adapter +(`worker.ts:36-37`). The worker count comes from `WORKER_COUNT`, else +`workers.count`, else `cpus().length`. The primary resolves the event-store backend once +(`app.ts:92-95`) and stamps `EVENT_STORE_BACKEND` into every fork's env. Drain runs on +SIGINT/SIGTERM through `src/utils/shutdown-state.ts`. The telemetry section of the +original spec named a `tracing-otlp` crate. ## Decisions @@ -18,10 +24,14 @@ named a `tracing-otlp` crate. 1. **tokio multi-thread runtime** for the daemon (`nostreamd`), with `enable_all`. 2. **A single acceptor task** owns the listener. No `SO_REUSEPORT`, no per-core - acceptor sharding in Phase 1. The TS relay is a single Node process; the - multi-worker fan-out is a Phase-2 decision to take against measurements, not - upfront. `Connection::accept` and the relay wire take the socket in Phase 2; - until then sockets are accepted and dropped with a `debug!` line. + acceptor sharding in Phase 1. The capture's model — a cluster primary forking + `cpus().length` client workers that all `listen()` the same port, plus the IPC hop + that re-emits cluster messages into each worker's adapter — is what Phase 2 decides + **against** or reproduces, on measurements, not upfront. (Round-1 correction: the + capture is not cluster-less; the original wording here inverted it.) Phase 1 keeps + the tokio equivalent of *one* worker: `Connection::accept` and the relay wire take + the socket in Phase 2; until then sockets are accepted and dropped with a `debug!` + line. 3. The listen port resolves as `--listen-port`, then `PORT`, then `RELAY_PORT`, then `8008` — the capture's rule, in order (`src/app/worker.ts:32`). 4. Drain: SIGINT/SIGTERM set `nostream_core::lifecycle::ShutdownState` From f4ee642504dc9c9f436b7068c23fbb7d857a9085 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 16:28:20 -0400 Subject: [PATCH 18/32] fix(config,core): type the secret-bearing fields as Secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit passwordHash, notification target url and botToken become Secret, whose Debug/Display redact — one tracing debug of a settings struct can no longer print an admin password hash or a webhook token (the round-1 security lens flagged them as plain Strings inside derive(Debug) structs). Secret's doc comment is narrowed to the per-field guarantee; serde's transparent passthrough keeps the document shape byte-identical. All 117 config tests and the 17-case parity fixture stay green. --- crates/nostream-config/src/notifications.rs | 14 ++++++++------ crates/nostream-config/src/settings.rs | 4 +++- .../nostream-config/tests/notifications_merge.rs | 4 ++-- crates/nostream-core/src/secret.rs | 7 +++++-- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/crates/nostream-config/src/notifications.rs b/crates/nostream-config/src/notifications.rs index d0676a968..08674f8f7 100644 --- a/crates/nostream-config/src/notifications.rs +++ b/crates/nostream-config/src/notifications.rs @@ -31,10 +31,11 @@ pub struct OperatorNotificationTarget { pub target_type: OperatorNotificationChannelType, #[serde(default, skip_serializing_if = "Option::is_none")] pub enabled: Option, + /// Typed `Secret` (the webhook URL embeds a token); see `Secret`'s doc comment. #[serde(skip_serializing_if = "Option::is_none")] - pub url: Option, + pub url: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub bot_token: Option, + pub bot_token: Option, #[serde(skip_serializing_if = "Option::is_none")] pub chat_id: Option, } @@ -97,10 +98,11 @@ pub struct NotificationTargetPatch { pub target_type: OperatorNotificationChannelType, #[serde(default, skip_serializing_if = "Option::is_none")] pub enabled: Option, + /// Typed `Secret` (the webhook URL embeds a token); see `Secret`'s doc comment. #[serde(skip_serializing_if = "Option::is_none")] - pub url: Option, + pub url: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub bot_token: Option, + pub bot_token: Option, #[serde(skip_serializing_if = "Option::is_none")] pub chat_id: Option, } @@ -226,13 +228,13 @@ fn merge_target_secrets( .or_else(|| existing.and_then(|target| target.chat_id.clone())), }; - if is_redacted_secret(incoming.url.as_deref()) { + if is_redacted_secret(incoming.url.as_ref().map(|secret| secret.expose())) { merged.url = existing.and_then(|target| target.url.clone()); } else if incoming.url.is_none() && existing.and_then(|target| target.url.clone()).is_some() { merged.url = existing.and_then(|target| target.url.clone()); } - if is_redacted_secret(incoming.bot_token.as_deref()) { + if is_redacted_secret(incoming.bot_token.as_ref().map(|secret| secret.expose())) { merged.bot_token = existing.and_then(|target| target.bot_token.clone()); } else if incoming.bot_token.is_none() && existing diff --git a/crates/nostream-config/src/settings.rs b/crates/nostream-config/src/settings.rs index 793f6c190..8e21d735c 100644 --- a/crates/nostream-config/src/settings.rs +++ b/crates/nostream-config/src/settings.rs @@ -631,8 +631,10 @@ pub struct AdminNip98Settings { #[serde(rename_all = "camelCase")] pub struct AdminSettings { pub enabled: bool, + /// Typed `Secret` so a `Debug` of the settings tree redacts it; the capture types it + /// `string`, and serde's transparent passthrough keeps the document byte-identical. #[serde(skip_serializing_if = "Option::is_none")] - pub password_hash: Option, + pub password_hash: Option, #[serde(skip_serializing_if = "Option::is_none")] pub session_ttl_seconds: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/crates/nostream-config/tests/notifications_merge.rs b/crates/nostream-config/tests/notifications_merge.rs index cae8696c0..89103fb13 100644 --- a/crates/nostream-config/tests/notifications_merge.rs +++ b/crates/nostream-config/tests/notifications_merge.rs @@ -32,7 +32,7 @@ fn merges_patch_values_and_preserves_redacted_webhook_secrets() { assert_eq!(next.targets[0].enabled, Some(false)); assert_eq!( - next.targets[0].url.as_deref(), + next.targets[0].url.as_ref().map(|secret| secret.expose()), Some("https://discord.com/api/webhooks/secret") ); } @@ -102,7 +102,7 @@ fn a_patch_replaces_targets_wholesale() { assert_eq!(next.targets.len(), 1); assert_eq!(next.targets[0].id, "replacement"); assert_eq!( - next.targets[0].url.as_deref(), + next.targets[0].url.as_ref().map(|secret| secret.expose()), Some("https://a.example/hook") ); } diff --git a/crates/nostream-core/src/secret.rs b/crates/nostream-core/src/secret.rs index 01aa6c75c..43bb1d8ef 100644 --- a/crates/nostream-core/src/secret.rs +++ b/crates/nostream-core/src/secret.rs @@ -2,9 +2,12 @@ use serde::{Deserialize, Serialize}; -/// A hex-encoded secret key. +/// A value that must not reach a log or an error message. /// -/// `Debug` never prints the value, so a logged settings struct cannot leak it. +/// `Debug` and `Display` redact, so a field typed `Secret` cannot leak through a `{:?}` or +/// `{}` of the settings struct that carries it. The guarantee is per-field: a field left a +/// plain `String` prints in the clear, and the document-level `redact_settings_secrets` +/// helper in `nostream-config` covers the document surface instead. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] pub struct Secret(pub String); From 5ec1efb72401932208686112d9117be39505cc1e Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 16:30:24 -0400 Subject: [PATCH 19/32] fix(config): keep operator scalars out of the typed-schema message; read-only config probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The typed-parse fallback issue reported serde's raw message, which renders the offending scalar (e.g. an out-of-i64 integer); it now reports the class and the parse location only. load_user_settings no longer mkdirs the config directory — a read probe returns None for an absent directory so nostreamd --check on a fresh host writes nothing; the test pins the new behaviour. --- crates/nostream-config/src/load.rs | 36 ++++++++++++++++++---------- crates/nostream-config/tests/load.rs | 9 ++++--- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/crates/nostream-config/src/load.rs b/crates/nostream-config/src/load.rs index 0e6b1ef35..91e14b544 100644 --- a/crates/nostream-config/src/load.rs +++ b/crates/nostream-config/src/load.rs @@ -62,11 +62,14 @@ pub fn load_document(path: &Path) -> Result { /// Reads the user's overrides: `settings.yaml` first, then the deprecated `settings.json`. /// /// Returns `None` when neither file exists. An empty YAML file is an empty override set. +/// +/// The directory is created only by the mutating callers (the guided editor, the save path); +/// a read probe reports `Ok(None)` for an absent directory instead of materialising one — +/// `nostreamd --check` runs this and must not write. pub fn load_user_settings(config_base_dir: &Path) -> Result, ConfigError> { - fs::create_dir_all(config_base_dir).map_err(|source| ConfigError::Read { - path: config_base_dir.to_path_buf(), - source, - })?; + if !config_base_dir.is_dir() { + return Ok(None); + } let settings_file = config_base_dir.join("settings.yaml"); if settings_file.is_file() { @@ -151,13 +154,22 @@ pub fn load(paths: &SettingsPaths) -> Result { issues, used_defaults: false, }), - Err(error) => Ok(LoadOutcome { - settings: defaults_settings, - issues: vec![Issue::new( - "$", - format!("settings do not fit the typed schema: {error}"), - )], - used_defaults: true, - }), + Err(error) => { + // serde's message renders the offending scalar (e.g. an out-of-i64 integer the + // shape walk cannot see), which would echo an operator-supplied value into + // stderr and logs. Report the class, not the value; the parse location rides + // along when serde exposes one. + let location = error.location().map_or_else(String::new, |location| { + format!(" at line {} column {}", location.line(), location.column()) + }); + Ok(LoadOutcome { + settings: defaults_settings, + issues: vec![Issue::new( + "$", + format!("settings do not fit the typed schema{location}"), + )], + used_defaults: true, + }) + } } } diff --git a/crates/nostream-config/tests/load.rs b/crates/nostream-config/tests/load.rs index 99cc5d25e..a0df453a8 100644 --- a/crates/nostream-config/tests/load.rs +++ b/crates/nostream-config/tests/load.rs @@ -40,12 +40,15 @@ fn the_shipped_defaults_load_clean() { } #[test] -fn the_loader_creates_the_config_directory() { +fn the_loader_does_not_create_the_config_directory() { + // The read probe is read-only: `nostreamd --check` on a fresh host must not materialise + // `.nostr/`. Directory creation belongs to the mutating callers (the guided editor, the + // save path). let config_dir = TempDir::new().expect("tempdir"); let target = config_dir.path().join("nested").join("config"); - let user = load_user_settings(&target).expect("the directory is created"); + let user = load_user_settings(&target).expect("an absent directory is not an error"); assert!(user.is_none()); - assert!(target.is_dir()); + assert!(!target.exists()); } #[test] From 33dd646078a6b2436bedec666c13cb261964d458 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 16:41:41 -0400 Subject: [PATCH 20/32] fix(daemon): --check returns before telemetry and serve (round-3 P0) run() fell through the gate into telemetry::init and serve, so --check bound 0.0.0.0:8008 and blocked on signals instead of exiting. The regression test executes the binary (CARGO_BIN_EXE_nostreamd) and asserts exit 0 with the clean-document line within a bound, plus exit 2 + the issue line on stderr for an invalid document. --- crates/nostreamd/src/main.rs | 3 ++ crates/nostreamd/tests/check_flag.rs | 54 ++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 crates/nostreamd/tests/check_flag.rs diff --git a/crates/nostreamd/src/main.rs b/crates/nostreamd/src/main.rs index 86821e8fd..7cd3ba6e3 100644 --- a/crates/nostreamd/src/main.rs +++ b/crates/nostreamd/src/main.rs @@ -62,6 +62,9 @@ fn run(cli: Cli) -> Result { let outcome = nostream_config::load(&paths).map_err(|error| DaemonError::Setup(error.to_string()))?; settings_gate::apply_settings_gate(&outcome, cli.check)?; + if cli.check { + return Ok(ExitCode::SUCCESS); + } telemetry::init( &outcome.settings, diff --git a/crates/nostreamd/tests/check_flag.rs b/crates/nostreamd/tests/check_flag.rs new file mode 100644 index 000000000..f284ce495 --- /dev/null +++ b/crates/nostreamd/tests/check_flag.rs @@ -0,0 +1,54 @@ +//! `--check` validates the settings and exits; it never installs telemetry, binds a port or +//! waits for a signal. Regression test for round 3's P0: the gate alone passed while `run()` +//! fell through into `serve`. + +use std::path::PathBuf; +use std::process::Command; +use std::time::{Duration, Instant}; + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../") + .canonicalize() + .expect("the workspace root exists") +} + +#[test] +fn check_exits_zero_on_a_valid_config_without_serving() { + let start = Instant::now(); + let output = Command::new(env!("CARGO_BIN_EXE_nostreamd")) + .arg("--check") + .current_dir(workspace_root()) + .output() + .expect("the binary spawns"); + + assert!( + start.elapsed() < Duration::from_secs(60), + "--check must not block; it took {:?}", + start.elapsed() + ); + assert!(output.status.success(), "exit {:?}", output.status.code()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout.trim(), "settings valid: 0 issues"); +} + +#[test] +fn check_exits_two_on_an_invalid_config() { + let directory = tempfile::tempdir().expect("tempdir"); + std::fs::write( + directory.path().join("settings.yaml"), + "limits:\n rateLimiter:\n strategy: random\n", + ) + .expect("write"); + + let output = Command::new(env!("CARGO_BIN_EXE_nostreamd")) + .arg("--check") + .arg("--config-dir") + .arg(directory.path()) + .current_dir(workspace_root()) + .output() + .expect("the binary spawns"); + assert_eq!(output.status.code(), Some(2)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("strategy must be ewma or sliding_window")); +} From c3d1cb05cc2365a513198d638ecf9f3809325330 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 16:44:46 -0400 Subject: [PATCH 21/32] fix(config,core): subtree-rooted redaction, file-as-config-dir finding, redacted Display, ADR citations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_redacted_admin_notifications wraps the subtree in {admin:{notifications}} exactly as the TS caller does, so the url rule matches admin.notifications.targets[...] — a bare targets[0].url sailed through while botToken redacted, hiding the gap; the new test roots at the subtree. A config path that exists but is not a directory is now a finding + defaults fallback, not silent absence. Secret gains the redacted Display its docs promised (with tests through Debug, Display and a carrier struct). ADR 0001 corrected: only AppWorker listens (worker.ts:33 after :32's port expression); the four specialist forks are app.ts:127-155. --- crates/nostream-config/src/error.rs | 8 ++++ crates/nostream-config/src/load.rs | 8 ++++ crates/nostream-config/src/notifications.rs | 22 ++++++++-- crates/nostream-config/tests/load.rs | 14 ++++++ crates/nostream-config/tests/redaction.rs | 44 +++++++++++++++++++ crates/nostream-core/src/secret.rs | 6 +++ crates/nostream-core/tests/secret.rs | 29 ++++++++++++ .../decisions/0001-runtime-and-telemetry.md | 31 +++++++------ 8 files changed, 145 insertions(+), 17 deletions(-) create mode 100644 crates/nostream-core/tests/secret.rs diff --git a/crates/nostream-config/src/error.rs b/crates/nostream-config/src/error.rs index 14f5b9c79..d457b0b53 100644 --- a/crates/nostream-config/src/error.rs +++ b/crates/nostream-config/src/error.rs @@ -36,6 +36,14 @@ pub enum ConfigError { #[error("settings do not fit the typed schema: {0}")] Typed(#[from] serde_yaml::Error), + /// The redaction walk dropped a subtree it was handed; never expected. + #[error("redaction dropped the notifications subtree")] + RedactionDroppedSubtree, + + /// The configured settings location exists but is not a directory. + #[error("settings path {path} exists but is not a directory")] + NotADirectory { path: std::path::PathBuf }, + /// The defaults document is not a mapping. #[error("{path} does not contain a settings mapping")] NotAMapping { path: PathBuf }, diff --git a/crates/nostream-config/src/load.rs b/crates/nostream-config/src/load.rs index 91e14b544..3ae8cadb7 100644 --- a/crates/nostream-config/src/load.rs +++ b/crates/nostream-config/src/load.rs @@ -67,6 +67,14 @@ pub fn load_document(path: &Path) -> Result { /// a read probe reports `Ok(None)` for an absent directory instead of materialising one — /// `nostreamd --check` runs this and must not write. pub fn load_user_settings(config_base_dir: &Path) -> Result, ConfigError> { + // A path that exists but is not a directory (a file, a broken symlink) is a finding, + // not silence: the operator configured a location the daemon cannot read. Absence + // stays `Ok(None)`. + if config_base_dir.exists() && !config_base_dir.is_dir() { + return Err(ConfigError::NotADirectory { + path: config_base_dir.to_path_buf(), + }); + } if !config_base_dir.is_dir() { return Ok(None); } diff --git a/crates/nostream-config/src/notifications.rs b/crates/nostream-config/src/notifications.rs index 08674f8f7..466e9b2ff 100644 --- a/crates/nostream-config/src/notifications.rs +++ b/crates/nostream-config/src/notifications.rs @@ -2,6 +2,7 @@ //! `src/@types/operator-notifications.ts`. use serde::{Deserialize, Serialize}; +use serde_yaml::Value; use crate::redaction::redact_settings_secrets; @@ -193,13 +194,28 @@ pub fn get_merged_admin_notifications( } /// The merged block with secrets replaced by `***`, for the admin UI. +/// +/// The subtree is wrapped in `{ admin: { notifications } }` exactly as the capture's caller +/// does (`src/utils/admin-notifications-settings.ts:28`): `redact_settings_secrets` classifies +/// by path, and the `url` rule matches `admin.notifications.targets[…]` — a bare +/// `targets[0].url` would sail through while `botToken` (a sensitive key anywhere) still +/// redacted, hiding the gap. pub fn get_redacted_admin_notifications( merged: &AdminNotificationsSettings, defaults: Option<&AdminNotificationsSettings>, ) -> Result { - let document = serde_yaml::to_value(merged)?; - let redacted = redact_settings_secrets(&document); - let settings: AdminNotificationsSettings = serde_yaml::from_value(redacted)?; + let notifications = serde_yaml::to_value(merged)?; + let mut wrapper = serde_yaml::Mapping::new(); + let mut admin = serde_yaml::Mapping::new(); + admin.insert(Value::String("notifications".to_owned()), notifications); + wrapper.insert(Value::String("admin".to_owned()), Value::Mapping(admin)); + let redacted = redact_settings_secrets(&Value::Mapping(wrapper)); + let redacted_notifications = redacted + .get("admin") + .and_then(|admin| admin.get("notifications")) + .cloned() + .ok_or(crate::ConfigError::RedactionDroppedSubtree)?; + let settings: AdminNotificationsSettings = serde_yaml::from_value(redacted_notifications)?; Ok(get_merged_admin_notifications(Some(&settings), defaults)) } diff --git a/crates/nostream-config/tests/load.rs b/crates/nostream-config/tests/load.rs index a0df453a8..a82722079 100644 --- a/crates/nostream-config/tests/load.rs +++ b/crates/nostream-config/tests/load.rs @@ -204,3 +204,17 @@ fn a_config_dir_override_wins_over_the_environment() { .join("default-settings.yaml") ); } + +#[test] +fn a_config_path_that_is_a_file_is_a_finding_not_silence() { + // Round 3 item 5: a location that exists but is not a directory must warn and fall + // back, exactly like any other unreadable user configuration. + let outer = TempDir::new().expect("tempdir"); + let impostor = outer.path().join("impostor"); + std::fs::write(&impostor, "not a directory").expect("write the file"); + + let outcome = load(&paths(&impostor)).expect("the loader never refuses"); + assert!(outcome.used_defaults); + assert_eq!(outcome.issues.len(), 1); + assert!(outcome.issues[0].to_string().contains("not a directory")); +} diff --git a/crates/nostream-config/tests/redaction.rs b/crates/nostream-config/tests/redaction.rs index 7b59f6ebc..3185737e9 100644 --- a/crates/nostream-config/tests/redaction.rs +++ b/crates/nostream-config/tests/redaction.rs @@ -100,3 +100,47 @@ fn a_non_secret_url_somewhere_else_is_left_alone() { assert!(!is_sensitive_settings_path("info.relay_url")); assert!(!is_sensitive_settings_path("mirroring.static[0].address")); } + +#[test] +fn the_subtree_root_still_redacts_webhook_urls() { + // Regression for round 3 item 4: every earlier redaction test rooted the walk at the + // full settings document, so paths carried `admin.notifications.`; the admin + // notifications getter roots it at the notifications subtree, and the bare-subtree + // serialization produced `targets[0].url`, which the `url` rule did not match — + // while botToken (a sensitive key anywhere) redacted, hiding the gap. + use nostream_config::notifications::{ + AdminNotificationsSettings, OperatorNotificationChannelType, + OperatorNotificationEventToggles, OperatorNotificationRetrySettings, + OperatorNotificationTarget, get_redacted_admin_notifications, + }; + + let settings = AdminNotificationsSettings { + enabled: true, + targets: vec![OperatorNotificationTarget { + id: "t1".to_owned(), + target_type: OperatorNotificationChannelType::Discord, + enabled: Some(true), + url: Some(nostream_core::Secret( + "https://discord.com/api/webhooks/token-value".to_owned(), + )), + bot_token: Some(nostream_core::Secret("bot-token-value".to_owned())), + chat_id: None, + }], + events: OperatorNotificationEventToggles::default(), + retry: OperatorNotificationRetrySettings::default(), + delivery_log_retention_days: None, + }; + + let redacted = get_redacted_admin_notifications(&settings, None).expect("redacts"); + let url = redacted.targets[0] + .url + .as_ref() + .map(|secret| secret.expose()); + let bot = redacted.targets[0] + .bot_token + .as_ref() + .map(|secret| secret.expose()); + assert_eq!(url, Some("***"), "the webhook url must redact"); + assert_eq!(bot, Some("***")); + assert!(!format!("{redacted:?}").contains("token-value")); +} diff --git a/crates/nostream-core/src/secret.rs b/crates/nostream-core/src/secret.rs index 43bb1d8ef..d7cd52804 100644 --- a/crates/nostream-core/src/secret.rs +++ b/crates/nostream-core/src/secret.rs @@ -22,6 +22,12 @@ impl std::fmt::Debug for Secret { } } +impl std::fmt::Display for Secret { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("***") + } +} + impl Secret { /// The raw value. Callers must not log it. pub fn expose(&self) -> &str { diff --git a/crates/nostream-core/tests/secret.rs b/crates/nostream-core/tests/secret.rs new file mode 100644 index 000000000..570d7c351 --- /dev/null +++ b/crates/nostream-core/tests/secret.rs @@ -0,0 +1,29 @@ +//! `Secret` redacts through every formatting path that reaches a log line. + +use nostream_core::Secret; + +#[test] +fn debug_and_display_redact() { + let secret = Secret("deadbeef".to_owned()); + assert_eq!(format!("{secret:?}"), "Secret(***)"); + assert_eq!(format!("{secret}"), "***"); + assert_eq!(format!("{}", Secret(String::new())), "***"); +} + +#[test] +fn a_struct_carrying_a_secret_redacts() { + #[derive(Debug)] + struct Carrier { + name: String, + token: Secret, + } + let rendered = format!( + "{:?}", + Carrier { + name: "relay".to_owned(), + token: Secret("s3cr3t".to_owned()), + } + ); + assert!(!rendered.contains("s3cr3t"), "{rendered}"); + assert!(rendered.contains("Secret(***)")); +} diff --git a/docs/rewrite/decisions/0001-runtime-and-telemetry.md b/docs/rewrite/decisions/0001-runtime-and-telemetry.md index ac9cb5058..fda2451c2 100644 --- a/docs/rewrite/decisions/0001-runtime-and-telemetry.md +++ b/docs/rewrite/decisions/0001-runtime-and-telemetry.md @@ -7,13 +7,16 @@ ## Context The Rust rewrite replaces a Node.js relay whose process model is **cluster-based**: -`src/index.ts` boots `App` in the primary, `src/app/app.ts:102-125` forks -`cpus().length` client workers (`WORKER_TYPE=worker`) plus dedicated -maintenance / static-mirroring / relay-monitor / DVM workers, and **every worker calls -`adapter.listen(port)`** (`src/app/worker.ts:32`) — the OS queues the connections and each -worker accepts, with cluster IPC re-emitting messages into the adapter -(`worker.ts:36-37`). The worker count comes from `WORKER_COUNT`, else -`workers.count`, else `cpus().length`. The primary resolves the event-store backend once +`src/index.ts` boots `App` in the primary; `src/app/app.ts:102-125` forks +`WORKER_COUNT`-else-`workers.count`-else-`cpus().length` client workers +(`WORKER_TYPE=worker`), and `app.ts:127-155` forks the specialist workers — one +maintenance, one static-mirroring per configured mirror, one relay-monitor when +`nip66.enabled`, and `dvm.workers.length` DVM orchestrators. **Only `AppWorker` listens**: +`src/app/worker.ts:32` computes the port (`PORT` || `RELAY_PORT` || 8008) and `:33` calls +`adapter.listen(port)`, so the capture runs one listener per client worker (the +specialists never touch the adapter) — the OS queues the connections and each client +worker accepts, with cluster IPC re-emitting messages into its adapter +(`worker.ts:36-37`). The primary resolves the event-store backend once (`app.ts:92-95`) and stamps `EVENT_STORE_BACKEND` into every fork's env. Drain runs on SIGINT/SIGTERM through `src/utils/shutdown-state.ts`. The telemetry section of the original spec named a `tracing-otlp` crate. @@ -25,13 +28,13 @@ original spec named a `tracing-otlp` crate. 1. **tokio multi-thread runtime** for the daemon (`nostreamd`), with `enable_all`. 2. **A single acceptor task** owns the listener. No `SO_REUSEPORT`, no per-core acceptor sharding in Phase 1. The capture's model — a cluster primary forking - `cpus().length` client workers that all `listen()` the same port, plus the IPC hop - that re-emits cluster messages into each worker's adapter — is what Phase 2 decides - **against** or reproduces, on measurements, not upfront. (Round-1 correction: the - capture is not cluster-less; the original wording here inverted it.) Phase 1 keeps - the tokio equivalent of *one* worker: `Connection::accept` and the relay wire take - the socket in Phase 2; until then sockets are accepted and dropped with a `debug!` - line. + `cpus().length` client workers that each `listen()` the same port (only client + workers listen; the specialist workers do not), plus the IPC hop that re-emits + cluster messages into each worker's adapter — is what Phase 2 decides **against** + or reproduces, on measurements, not upfront. (Round-1 correction: the capture is + not cluster-less; the original wording here inverted it.) Phase 1 keeps the tokio + equivalent of *one* worker: `Connection::accept` and the relay wire take the socket + in Phase 2; until then sockets are accepted and dropped with a `debug!` line. 3. The listen port resolves as `--listen-port`, then `PORT`, then `RELAY_PORT`, then `8008` — the capture's rule, in order (`src/app/worker.ts:32`). 4. Drain: SIGINT/SIGTERM set `nostream_core::lifecycle::ShutdownState` From e6759b5cde072d502bb93cfe42b842271f1727b0 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 16:45:42 -0400 Subject: [PATCH 22/32] fix(storage,ci): every heed precondition named and located; checkout SHA-pinned without a persisted token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unsafe comment lists each precondition heed documents — long-lived transactions, aborting with an active transaction, network filesystems, NO_LOCK/lock-file ownership, one map_size per path — and marks each upheld by the code or relied on the operator (the two operator-relied items name where Phase 2 closes them). The rust-checks checkout pins actions/checkout@11d5960a… # v4.4.0 (SHA measured from the remote) with persist-credentials: false, so the workflow token does not survive into the clone; the pre-existing Node jobs keep their own checkout lines. --- .github/workflows/checks.yml | 7 +++++- crates/nostream-storage/src/backend.rs | 33 +++++++++++++++++--------- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 5114094ac..151bf240f 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -226,8 +226,13 @@ jobs: permissions: contents: read steps: + # SHA-pinned (v4.4.0) with the workflow token not persisted into the clone — the + # other two Rust actions are pinned the same way; the pre-existing Node jobs keep + # their own checkout lines. - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false # rust-toolchain.toml is read automatically by the action's discovery (no toolchain-file # input exists; passing one only warns). Both actions are SHA-pinned per the house # convention the docker/* actions below already follow. diff --git a/crates/nostream-storage/src/backend.rs b/crates/nostream-storage/src/backend.rs index 368b16c20..8e186838e 100644 --- a/crates/nostream-storage/src/backend.rs +++ b/crates/nostream-storage/src/backend.rs @@ -49,14 +49,24 @@ impl std::fmt::Display for Backend { /// heed itself upholds the in-process part: it canonicalizes the path, deduplicates through /// a process-global registry, returns the *same* `Env` for a second open with equal options /// and `Error::BadOpenOptions` when they differ (heed-0.20.5 `src/env.rs`, `OPENED_ENV`). -/// What this crate must therefore uphold — heed's documented preconditions, unchanged here: +/// heed's documented preconditions, and where each stands today: /// -/// - never open an environment with `NO_LOCK` (concurrent access becomes the caller's job), -/// `NO_SYNC` or `NO_META_SYNC` (durability is silently dropped) — none is used; -/// - never point an environment at a network filesystem — the lock file semantics do not -/// survive it; the Phase-2 backend factory must refuse one; -/// - keep one `map_size` per path for the life of the process — a second open with a -/// different size surfaces as `Error::BadOpenOptions`, which `open` propagates. +/// - **No long-lived transactions** — upheld by the code: `put`/`get` open a transaction, +/// use it, and commit/drop it before returning; nothing holds one across an await. +/// - **No aborting the process with an active transaction** — relied on the operator for +/// SIGKILL-class exits; the Phase-2 daemon drain path must ensure a clean shutdown +/// (SIGINT/SIGTERM drain through `ShutdownState` does not abort mid-transaction). +/// - **No network filesystems** — relied on the operator today: `open` accepts any path and +/// only `create_dir_all` before mapping; the Phase-2 backend factory must refuse a remote +/// location before this slice is wired into settings. +/// - **`NO_LOCK` requires self-managed concurrency; a broken lock file means +/// synchronization issues and possible UB** — upheld by the code: no env flags are passed +/// (`EnvOpenOptions::new()` only), so LMDB's own locking runs and the lock file is owned +/// by LMDB, never touched by this crate. +/// - **One `map_size` per path for the life of the process** — upheld by heed + propagated: +/// heed's per-process `OPENED_ENV` registry returns the same `Env` for a second open with +/// equal options and `Error::BadOpenOptions` when they differ, and `open` surfaces that +/// error to the caller rather than opening a second environment. #[derive(Debug, Clone)] pub struct LmdbEnv { env: Env, @@ -87,10 +97,11 @@ impl LmdbEnv { source, })?; - // # Safety: upheld by the preconditions on `LmdbEnv` above — no NO_LOCK/NO_SYNC - // flags, a local (non-network) path, and heed's own per-process dedup making a - // second open with the same options return the same environment rather than a - // second one. See heed-0.20.5's documented safety contract (`src/env.rs`). + // # Safety: the preconditions on `LmdbEnv` above, each marked upheld or relied-on: + // no env flags (LMDB's locking runs), transactions never escape the call, and + // heed's per-process dedup turns a second open into the same Env or + // BadOpenOptions. The network-filesystem precondition rests on the operator + // passing a local path; see the list above. #[allow(unsafe_code)] let env = unsafe { EnvOpenOptions::new().map_size(map_size).open(&path) }.map_err(|source| { From e48bf976720238c9972bb347632a94a4a4b35682 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 16:46:19 -0400 Subject: [PATCH 23/32] fix(core): allow the formatting-only carrier fields (clippy) --- crates/nostream-core/tests/secret.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/nostream-core/tests/secret.rs b/crates/nostream-core/tests/secret.rs index 570d7c351..c38aa2d56 100644 --- a/crates/nostream-core/tests/secret.rs +++ b/crates/nostream-core/tests/secret.rs @@ -13,6 +13,7 @@ fn debug_and_display_redact() { #[test] fn a_struct_carrying_a_secret_redacts() { #[derive(Debug)] + #[allow(dead_code)] // the fields exist to be formatted; only the rendering is asserted struct Carrier { name: String, token: Secret, From 1be80b2018c1107ce388540f19fd2c4051086ce1 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 16:53:51 -0400 Subject: [PATCH 24/32] fix(config,daemon,storage): round-4 review items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The config-path probe is symlink-aware via symlink_metadata: a stat-able non-directory (file, dangling link, fifo) is NotADirectory, any non-NotFound stat error is the new StatSettingsPath, and only plain absence stays Ok(None) — with dangling-link and file-parent tests. The --check tests now spawn + poll try_wait against a 30s deadline and kill on expiry, so a reintroduced fall-through fails the suite instead of parking it. ADR 0001 cites the client workers at app.ts:103-120, maintenance at :122-125 and the rest at :127-155; nostreamd's header says the primary never listens. The NO_LOCK/lock-file precondition is split: the code guarantees no flags and never touches the lock file; the operator must keep the lock file intact. --- crates/nostream-config/src/error.rs | 9 +++ crates/nostream-config/src/load.rs | 33 +++++--- crates/nostream-config/tests/load.rs | 30 ++++++++ crates/nostream-storage/src/backend.rs | 10 ++- crates/nostreamd/src/lib.rs | 5 +- crates/nostreamd/tests/check_flag.rs | 76 +++++++++++++------ .../decisions/0001-runtime-and-telemetry.md | 8 +- 7 files changed, 130 insertions(+), 41 deletions(-) diff --git a/crates/nostream-config/src/error.rs b/crates/nostream-config/src/error.rs index d457b0b53..568911633 100644 --- a/crates/nostream-config/src/error.rs +++ b/crates/nostream-config/src/error.rs @@ -44,6 +44,15 @@ pub enum ConfigError { #[error("settings path {path} exists but is not a directory")] NotADirectory { path: std::path::PathBuf }, + /// The configured settings location could not be stat-ed (unreadable parent, symlink + /// loop, permission denied) — treated as a finding, not as absence. + #[error("settings path {path} could not be inspected: {source}")] + StatSettingsPath { + path: std::path::PathBuf, + #[source] + source: std::io::Error, + }, + /// The defaults document is not a mapping. #[error("{path} does not contain a settings mapping")] NotAMapping { path: PathBuf }, diff --git a/crates/nostream-config/src/load.rs b/crates/nostream-config/src/load.rs index 3ae8cadb7..461933be4 100644 --- a/crates/nostream-config/src/load.rs +++ b/crates/nostream-config/src/load.rs @@ -67,16 +67,29 @@ pub fn load_document(path: &Path) -> Result { /// a read probe reports `Ok(None)` for an absent directory instead of materialising one — /// `nostreamd --check` runs this and must not write. pub fn load_user_settings(config_base_dir: &Path) -> Result, ConfigError> { - // A path that exists but is not a directory (a file, a broken symlink) is a finding, - // not silence: the operator configured a location the daemon cannot read. Absence - // stays `Ok(None)`. - if config_base_dir.exists() && !config_base_dir.is_dir() { - return Err(ConfigError::NotADirectory { - path: config_base_dir.to_path_buf(), - }); - } - if !config_base_dir.is_dir() { - return Ok(None); + // The probe is symlink-aware: `Path::exists` follows links, so a dangling link (or an + // unreadable parent — EACCES, ELOOP) would fall through to `Ok(None)` and `--check` + // would print a clean bill for a location the daemon cannot read. `symlink_metadata` + // does not follow the final component: an entry that cannot be stat-ed at all, link or + // not, is a finding; a stat-able non-directory (file, dangling link, fifo, …) is a + // finding; only true absence is `Ok(None)`. + match std::fs::symlink_metadata(config_base_dir) { + Ok(metadata) if metadata.is_dir() => {} + Ok(_) => { + return Err(ConfigError::NotADirectory { + path: config_base_dir.to_path_buf(), + }); + } + // Plain absence stays `Ok(None)` — the read-only probe contract. Any other stat + // failure (EACCES on a parent, ELOOP, ENOTDIR) is loud: the operator named a + // location the daemon cannot even inspect. + Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(source) => { + return Err(ConfigError::StatSettingsPath { + path: config_base_dir.to_path_buf(), + source, + }); + } } let settings_file = config_base_dir.join("settings.yaml"); diff --git a/crates/nostream-config/tests/load.rs b/crates/nostream-config/tests/load.rs index a82722079..dc85ae023 100644 --- a/crates/nostream-config/tests/load.rs +++ b/crates/nostream-config/tests/load.rs @@ -218,3 +218,33 @@ fn a_config_path_that_is_a_file_is_a_finding_not_silence() { assert_eq!(outcome.issues.len(), 1); assert!(outcome.issues[0].to_string().contains("not a directory")); } + +#[test] +fn a_dangling_config_symlink_is_a_finding_not_silence() { + // Round 4 item 1: `Path::exists` follows links, so a dangling `--config-dir` link used + // to fall through to `Ok(None)` and `--check` printed a clean bill. + let outer = TempDir::new().expect("tempdir"); + let link = outer.path().join("dangling"); + #[cfg(unix)] + std::os::unix::fs::symlink(outer.path().join("never-created"), &link) + .expect("create the dangling link"); + + let outcome = load(&paths(&link)).expect("the loader never refuses"); + assert!(outcome.used_defaults); + assert_eq!(outcome.issues.len(), 1); + assert!(outcome.issues[0].to_string().contains("not a directory")); +} + +#[test] +fn a_config_dir_behind_an_unreadable_parent_is_a_finding() { + // A stat error on the path itself (here: a parent that is a file, EACCES/ENOTDIR on + // most systems) is a sibling finding, not silent absence. + let outer = TempDir::new().expect("tempdir"); + let file_parent = outer.path().join("a-file"); + std::fs::write(&file_parent, "x").expect("write"); + let beneath = file_parent.join("settings"); + + let outcome = load(&paths(&beneath)).expect("the loader never refuses"); + assert!(outcome.used_defaults); + assert_eq!(outcome.issues.len(), 1); +} diff --git a/crates/nostream-storage/src/backend.rs b/crates/nostream-storage/src/backend.rs index 8e186838e..1ad0ba18c 100644 --- a/crates/nostream-storage/src/backend.rs +++ b/crates/nostream-storage/src/backend.rs @@ -60,9 +60,13 @@ impl std::fmt::Display for Backend { /// only `create_dir_all` before mapping; the Phase-2 backend factory must refuse a remote /// location before this slice is wired into settings. /// - **`NO_LOCK` requires self-managed concurrency; a broken lock file means -/// synchronization issues and possible UB** — upheld by the code: no env flags are passed -/// (`EnvOpenOptions::new()` only), so LMDB's own locking runs and the lock file is owned -/// by LMDB, never touched by this crate. +/// synchronization issues and possible UB** — split responsibility. The code guarantees +/// its half: no env flags are passed (`EnvOpenOptions::new()` only), so `NO_LOCK` is +/// never in play and this crate never reads, writes or removes the lock file. The +/// operator must supply the rest: the data directory's lock file must not be broken +/// (stale locks left by a crashed process on a filesystem without proper flock support, +/// manual deletion, two processes on different hosts sharing a volume — LMDB cannot +/// recover from any of those, per heed's contract). /// - **One `map_size` per path for the life of the process** — upheld by heed + propagated: /// heed's per-process `OPENED_ENV` registry returns the same `Env` for a second open with /// equal options and `Error::BadOpenOptions` when they differ, and `open` surfaces that diff --git a/crates/nostreamd/src/lib.rs b/crates/nostreamd/src/lib.rs index c03bc850e..0578c9b69 100644 --- a/crates/nostreamd/src/lib.rs +++ b/crates/nostreamd/src/lib.rs @@ -17,8 +17,9 @@ //! `dvm-orchestrator-worker-factory` //! //! Runtime decision (ADR `0001-runtime-and-telemetry.md`): a single acceptor task, no -//! `SO_REUSEPORT` sharding — the capture's cluster fan-out (primary + `cpus().length` -//! client workers all listening, IPC into each adapter) is the model Phase 2 decides +//! `SO_REUSEPORT` sharding — the capture's cluster fan-out (a primary forking +//! `cpus().length` client workers that each `listen()` the same port, IPC into each +//! worker's own adapter; the primary itself never listens) is the model Phase 2 decides //! against. Phase 1 binds, accepts, drains on SIGINT/SIGTERM through //! `nostream_core::lifecycle::ShutdownState` and drops sockets with a debug line; the relay //! wire takes the socket in Phase 2. diff --git a/crates/nostreamd/tests/check_flag.rs b/crates/nostreamd/tests/check_flag.rs index f284ce495..0ba327cc1 100644 --- a/crates/nostreamd/tests/check_flag.rs +++ b/crates/nostreamd/tests/check_flag.rs @@ -3,7 +3,7 @@ //! fell through into `serve`. use std::path::PathBuf; -use std::process::Command; +use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; fn workspace_root() -> PathBuf { @@ -13,22 +13,57 @@ fn workspace_root() -> PathBuf { .expect("the workspace root exists") } -#[test] -fn check_exits_zero_on_a_valid_config_without_serving() { +/// Runs `nostreamd --check …` with a hard deadline: `Command::output` blocks until the +/// child exits, so a reintroduced fall-through would park the suite until CI's timeout +/// instead of failing here. On expiry the child is killed and the assertion fires. +fn run_check(deadline: Duration, args: &[&str]) -> (Option, String, String) { let start = Instant::now(); - let output = Command::new(env!("CARGO_BIN_EXE_nostreamd")) - .arg("--check") + let mut child = Command::new(env!("CARGO_BIN_EXE_nostreamd")) + .args(args) .current_dir(workspace_root()) - .output() + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() .expect("the binary spawns"); - assert!( - start.elapsed() < Duration::from_secs(60), - "--check must not block; it took {:?}", - start.elapsed() - ); - assert!(output.status.success(), "exit {:?}", output.status.code()); - let stdout = String::from_utf8_lossy(&output.stdout); + loop { + match child.try_wait().expect("wait works") { + Some(status) => { + assert!( + start.elapsed() < deadline, + "--check must not block; it took {:?}", + start.elapsed() + ); + let mut stdout = String::new(); + let mut stderr = String::new(); + if let Some(mut pipe) = child.stdout.take() { + use std::io::Read; + let _ = pipe.read_to_string(&mut stdout); + } + if let Some(mut pipe) = child.stderr.take() { + use std::io::Read; + let _ = pipe.read_to_string(&mut stderr); + } + return (status.code(), stdout, stderr); + } + None => { + if start.elapsed() > deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!( + "--check must not block; it was still running after {deadline:?} and was killed" + ); + } + std::thread::sleep(Duration::from_millis(20)); + } + } + } +} + +#[test] +fn check_exits_zero_on_a_valid_config_without_serving() { + let (code, stdout, _stderr) = run_check(Duration::from_secs(30), &["--check"]); + assert_eq!(code, Some(0), "exit code"); assert_eq!(stdout.trim(), "settings valid: 0 issues"); } @@ -41,14 +76,11 @@ fn check_exits_two_on_an_invalid_config() { ) .expect("write"); - let output = Command::new(env!("CARGO_BIN_EXE_nostreamd")) - .arg("--check") - .arg("--config-dir") - .arg(directory.path()) - .current_dir(workspace_root()) - .output() - .expect("the binary spawns"); - assert_eq!(output.status.code(), Some(2)); - let stderr = String::from_utf8_lossy(&output.stderr); + let config_dir = directory.path().to_string_lossy().into_owned(); + let (code, _stdout, stderr) = run_check( + Duration::from_secs(30), + &["--check", "--config-dir", &config_dir], + ); + assert_eq!(code, Some(2), "exit code"); assert!(stderr.contains("strategy must be ewma or sliding_window")); } diff --git a/docs/rewrite/decisions/0001-runtime-and-telemetry.md b/docs/rewrite/decisions/0001-runtime-and-telemetry.md index fda2451c2..efc2b1e5f 100644 --- a/docs/rewrite/decisions/0001-runtime-and-telemetry.md +++ b/docs/rewrite/decisions/0001-runtime-and-telemetry.md @@ -7,11 +7,11 @@ ## Context The Rust rewrite replaces a Node.js relay whose process model is **cluster-based**: -`src/index.ts` boots `App` in the primary; `src/app/app.ts:102-125` forks +`src/index.ts` boots `App` in the primary; `app.ts:103-120` forks `WORKER_COUNT`-else-`workers.count`-else-`cpus().length` client workers -(`WORKER_TYPE=worker`), and `app.ts:127-155` forks the specialist workers — one -maintenance, one static-mirroring per configured mirror, one relay-monitor when -`nip66.enabled`, and `dvm.workers.length` DVM orchestrators. **Only `AppWorker` listens**: +(`WORKER_TYPE=worker`), `app.ts:122-125` forks one maintenance worker, and +`app.ts:127-155` forks the rest — one static-mirroring per configured mirror, one +relay-monitor when `nip66.enabled`, and `dvm.workers.length` DVM orchestrators. **Only `AppWorker` listens**: `src/app/worker.ts:32` computes the port (`PORT` || `RELAY_PORT` || 8008) and `:33` calls `adapter.listen(port)`, so the capture runs one listener per client worker (the specialists never touch the adapter) — the OS queues the connections and each client From bb659c0fc9e1778324f8d5b9e9342b9c72bf13d1 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 17:00:22 -0400 Subject: [PATCH 25/32] fix(config): accept a symlinked config dir; find dangling links with trailing separators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-4 probe used lstat, which does not resolve the final component, so a config dir that is a symlink to a real directory — a location the capture accepts via existsSync — substituted the defaults. The probe now decides is_dir with fs::metadata (follows links, capture parity); on NotFound it lstats the path with trailing separators trimmed (POSIX resolves a trailing slash through the link), making a dangling link entry a finding and true absence the only Ok(None); other stat errors stay StatSettingsPath. Both new tests were run against 1be80b2's predicate and fail there, pass here. --- crates/nostream-config/src/load.rs | 59 ++++++++++++++++++++++------ crates/nostream-config/tests/load.rs | 42 ++++++++++++++++++++ 2 files changed, 90 insertions(+), 11 deletions(-) diff --git a/crates/nostream-config/src/load.rs b/crates/nostream-config/src/load.rs index 461933be4..259347bf9 100644 --- a/crates/nostream-config/src/load.rs +++ b/crates/nostream-config/src/load.rs @@ -59,6 +59,24 @@ pub fn load_document(path: &Path) -> Result { Ok(document) } +/// Trims trailing `/` (and `\` on Windows) from a path for lstat: POSIX resolves a trailing +/// slash through a symlink, so `…/link/` on a dangling link reports ENOENT rather than the +/// link itself; trimming is what lets the second probe see the entry. +fn trim_trailing_separators(path: &Path) -> &Path { + let mut path = path; + while let Some(last) = path.to_str().and_then(|text| text.chars().last()) { + if last == '/' || (cfg!(windows) && last == '\\') { + match path.parent() { + Some(parent) if parent != path => path = parent, + _ => break, + } + } else { + break; + } + } + path +} + /// Reads the user's overrides: `settings.yaml` first, then the deprecated `settings.json`. /// /// Returns `None` when neither file exists. An empty YAML file is an empty override set. @@ -67,23 +85,42 @@ pub fn load_document(path: &Path) -> Result { /// a read probe reports `Ok(None)` for an absent directory instead of materialising one — /// `nostreamd --check` runs this and must not write. pub fn load_user_settings(config_base_dir: &Path) -> Result, ConfigError> { - // The probe is symlink-aware: `Path::exists` follows links, so a dangling link (or an - // unreadable parent — EACCES, ELOOP) would fall through to `Ok(None)` and `--check` - // would print a clean bill for a location the daemon cannot read. `symlink_metadata` - // does not follow the final component: an entry that cannot be stat-ed at all, link or - // not, is a finding; a stat-able non-directory (file, dangling link, fifo, …) is a - // finding; only true absence is `Ok(None)`. - match std::fs::symlink_metadata(config_base_dir) { + // The probe is symlink-aware, in the capture's direction: `fs::metadata` follows the + // final component (the capture's `existsSync` does too), so a config dir that is a + // symlink to a real directory is accepted and its target is used. When the followed + // stat reports NotFound, `symlink_metadata` (lstat, trailing separators trimmed — + // POSIX resolves a trailing slash through the link) distinguishes a dangling link + // entry (`NotADirectory`) from true absence (`Ok(None)`); any other followed-stat + // error (EACCES on a parent, ELOOP, ENOTDIR) is loud: the operator named a location + // the daemon cannot even inspect. + match std::fs::metadata(config_base_dir) { Ok(metadata) if metadata.is_dir() => {} Ok(_) => { return Err(ConfigError::NotADirectory { path: config_base_dir.to_path_buf(), }); } - // Plain absence stays `Ok(None)` — the read-only probe contract. Any other stat - // failure (EACCES on a parent, ELOOP, ENOTDIR) is loud: the operator named a - // location the daemon cannot even inspect. - Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(followed) if followed.kind() == std::io::ErrorKind::NotFound => { + let without_trailing = trim_trailing_separators(config_base_dir); + match std::fs::symlink_metadata(without_trailing) { + Ok(_) => { + // The entry itself exists (a dangling link, or a link with trailing + // separators that POSIX forced us to resolve): a finding, not silence. + return Err(ConfigError::NotADirectory { + path: config_base_dir.to_path_buf(), + }); + } + Err(link_error) if link_error.kind() == std::io::ErrorKind::NotFound => { + return Ok(None); + } + Err(source) => { + return Err(ConfigError::StatSettingsPath { + path: config_base_dir.to_path_buf(), + source, + }); + } + } + } Err(source) => { return Err(ConfigError::StatSettingsPath { path: config_base_dir.to_path_buf(), diff --git a/crates/nostream-config/tests/load.rs b/crates/nostream-config/tests/load.rs index dc85ae023..c23818f09 100644 --- a/crates/nostream-config/tests/load.rs +++ b/crates/nostream-config/tests/load.rs @@ -248,3 +248,45 @@ fn a_config_dir_behind_an_unreadable_parent_is_a_finding() { assert!(outcome.used_defaults); assert_eq!(outcome.issues.len(), 1); } + +#[test] +fn a_symlink_to_a_real_directory_is_accepted() { + // Round 5 regression: the round-4 probe used lstat, which does not resolve the final + // component, so a config dir that is a symlink to a real directory — a location the + // capture accepts (existsSync follows links) — substituted the defaults. (Fails + // against 1be80b2.) + let outer = TempDir::new().expect("tempdir"); + let real = outer.path().join("real-config"); + std::fs::create_dir_all(&real).expect("create the real directory"); + std::fs::write(real.join("settings.yaml"), "info:\n name: linked-relay\n") + .expect("write the override"); + + let link = outer.path().join("config-link"); + #[cfg(unix)] + std::os::unix::fs::symlink(&real, &link).expect("create the link"); + #[cfg(not(unix))] + std::os::windows::fs::symlink_dir(&real, &link).expect("create the link"); + + let outcome = load(&paths(&link)).expect("the loader never refuses"); + assert!(!outcome.used_defaults, "the symlinked dir must load"); + assert!(outcome.issues.is_empty()); + assert_eq!(outcome.settings.info.name, "linked-relay"); +} + +#[test] +fn a_dangling_link_with_a_trailing_separator_is_a_finding() { + // POSIX resolves a trailing slash through the link, so `…/link/` on a dangling link + // stats as ENOENT; the probe trims the separators so the second probe sees the link + // entry and reports the finding instead of silence. + let outer = TempDir::new().expect("tempdir"); + let link = outer.path().join("dangling"); + #[cfg(unix)] + std::os::unix::fs::symlink(outer.path().join("never-created"), &link) + .expect("create the dangling link"); + + let with_separator = PathBuf::from(format!("{}/", link.display())); + let outcome = load(&paths(&with_separator)).expect("the loader never refuses"); + assert!(outcome.used_defaults); + assert_eq!(outcome.issues.len(), 1); + assert!(outcome.issues[0].to_string().contains("not a directory")); +} From 725bb39f21e14c4083c817d1f0dfff61249dc1ed Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 17:05:32 -0400 Subject: [PATCH 26/32] fix(config): trim separator bytes, not components, in the lstat helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Path::parent drops the final component, so …/config/ trimmed to …/ — an absent config dir named with a trailing slash produced a bogus NotADirectory on a fresh host, and a relative single-component dangling link with a trailing slash trimmed to empty and took silent Ok(None). The helper now trims trailing separator bytes from the path's own text on OsStr bytes (non-UTF-8 safe; a bare root is left itself). Both new tests were run against bb659c0's helper and fail there, pass here. --- crates/nostream-config/src/load.rs | 46 +++++++++++++++++++--------- crates/nostream-config/tests/load.rs | 36 ++++++++++++++++++++++ 2 files changed, 68 insertions(+), 14 deletions(-) diff --git a/crates/nostream-config/src/load.rs b/crates/nostream-config/src/load.rs index 259347bf9..9ccca8586 100644 --- a/crates/nostream-config/src/load.rs +++ b/crates/nostream-config/src/load.rs @@ -5,7 +5,10 @@ //! validate never aborts the process. It is reported as [`Issue`] values and the caller gets //! the shipped defaults, so a broken `settings.yaml` cannot stop a relay from starting. +use std::ffi::OsStr; use std::fs; +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt; use std::path::Path; use serde_yaml::Value; @@ -59,22 +62,37 @@ pub fn load_document(path: &Path) -> Result { Ok(document) } -/// Trims trailing `/` (and `\` on Windows) from a path for lstat: POSIX resolves a trailing -/// slash through a symlink, so `…/link/` on a dangling link reports ENOENT rather than the -/// link itself; trimming is what lets the second probe see the entry. +/// Trims trailing separators from a path for lstat: POSIX resolves a trailing slash through +/// a symlink, so `…/link/` on a dangling link reports ENOENT rather than the link itself; +/// trimming is what lets the second probe see the entry. +/// +/// The trim removes separator *bytes from the path's own text* — not components: `…/config/` +/// must become `…/config`, not `…`. Root `/` is left as `/` (a root has nothing to probe), +/// and non-UTF-8 paths are handled because the work is on `OsStr` bytes, not a `to_str`. +#[cfg(unix)] fn trim_trailing_separators(path: &Path) -> &Path { - let mut path = path; - while let Some(last) = path.to_str().and_then(|text| text.chars().last()) { - if last == '/' || (cfg!(windows) && last == '\\') { - match path.parent() { - Some(parent) if parent != path => path = parent, - _ => break, - } - } else { - break; - } + use std::os::unix::ffi::OsStrExt; + + let bytes = path.as_os_str().as_bytes(); + let mut end = bytes.len(); + while end > 1 && bytes[end - 1] == b'/' { + end -= 1; + } + OsStr::from_bytes(&bytes[..end]).as_ref() +} + +/// Windows twin of the unix trim: drop trailing `\` bytes, never the last one (a bare +/// `C:\` root stays itself). +#[cfg(windows)] +fn trim_trailing_separators(path: &Path) -> &Path { + use std::os::windows::ffi::OsStrExt; + + let bytes = path.as_os_str().as_encoded_bytes(); + let mut end = bytes.len(); + while end > 1 && bytes[end - 1] == b'\\' { + end -= 1; } - path + OsStr::from_encoded_bytes_unchecked(&bytes[..end]).as_ref() } /// Reads the user's overrides: `settings.yaml` first, then the deprecated `settings.json`. diff --git a/crates/nostream-config/tests/load.rs b/crates/nostream-config/tests/load.rs index c23818f09..2c6dad2d6 100644 --- a/crates/nostream-config/tests/load.rs +++ b/crates/nostream-config/tests/load.rs @@ -290,3 +290,39 @@ fn a_dangling_link_with_a_trailing_separator_is_a_finding() { assert_eq!(outcome.issues.len(), 1); assert!(outcome.issues[0].to_string().contains("not a directory")); } + +#[test] +fn an_absent_config_dir_with_a_trailing_separator_is_clean_absence() { + // Round 6: the old trim used Path::parent, so `/srv/relay/config/` landed on + // `/srv/relay` — a real directory — and produced a bogus NotADirectory finding on a + // fresh host. The trim must remove the separator bytes, not the component. (Fails + // against bb659c0.) + let outer = TempDir::new().expect("tempdir"); + let absent = outer.path().join("never-created"); + let with_separator = PathBuf::from(format!("{}/", absent.display())); + + let outcome = load(&paths(&with_separator)).expect("the loader never refuses"); + assert!(!outcome.used_defaults, "clean absence is not a fallback"); + assert!(outcome.issues.is_empty()); +} + +#[test] +fn a_relative_single_component_dangling_link_with_a_trailing_separator_is_a_finding() { + // The other measured consequence: `dangling/` trimmed to `""` under Path::parent, and + // lstat on "" is ENOENT, which took Ok(None) — silent absence where the comment + // promises a finding. (Fails against bb659c0.) + let outer = TempDir::new().expect("tempdir"); + let previous = std::env::current_dir().expect("cwd"); + std::env::set_current_dir(outer.path()).expect("chdir into the tempdir"); + + #[cfg(unix)] + std::os::unix::fs::symlink("never-created", "dangling").expect("create the link"); + + let outcome = load(&paths(Path::new("dangling/"))); + std::env::set_current_dir(previous).expect("restore the cwd"); + + let outcome = outcome.expect("the loader never refuses"); + assert!(outcome.used_defaults); + assert_eq!(outcome.issues.len(), 1); + assert!(outcome.issues[0].to_string().contains("not a directory")); +} From 9489c3f79c7c1555710b6cdf89ad3bf0c9d64787 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 17:06:45 -0400 Subject: [PATCH 27/32] fix(config): drop the duplicate OsStrExt import (clippy) --- crates/nostream-config/src/load.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/nostream-config/src/load.rs b/crates/nostream-config/src/load.rs index 9ccca8586..e5cf64427 100644 --- a/crates/nostream-config/src/load.rs +++ b/crates/nostream-config/src/load.rs @@ -7,8 +7,6 @@ use std::ffi::OsStr; use std::fs; -#[cfg(unix)] -use std::os::unix::ffi::OsStrExt; use std::path::Path; use serde_yaml::Value; From 893ba79e6a1e183cd7179892a4710fa000e12de1 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 17:09:55 -0400 Subject: [PATCH 28/32] fix(config): make the windows trim safe and cover it on every platform --- crates/nostream-config/src/load.rs | 69 +++++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/crates/nostream-config/src/load.rs b/crates/nostream-config/src/load.rs index e5cf64427..d00fc4b8c 100644 --- a/crates/nostream-config/src/load.rs +++ b/crates/nostream-config/src/load.rs @@ -7,7 +7,7 @@ use std::ffi::OsStr; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use serde_yaml::Value; @@ -79,18 +79,31 @@ fn trim_trailing_separators(path: &Path) -> &Path { OsStr::from_bytes(&bytes[..end]).as_ref() } -/// Windows twin of the unix trim: drop trailing `\` bytes, never the last one (a bare -/// `C:\` root stays itself). +/// Windows twin of the unix trim: drop trailing `\` (and `/`) separators, never the last +/// character (a bare `C:\` root stays itself). +/// +/// Safe on purpose: the unix arm can slice `OsStr` bytes, but `OsStr::from_encoded_bytes_unchecked` +/// is `unsafe`, which the workspace denies. A lossy round-trip is honest here — the result is only +/// ever `lstat`-ed, and a Windows path that is not valid UTF-8 has no `settings.yaml` we can read. #[cfg(windows)] -fn trim_trailing_separators(path: &Path) -> &Path { - use std::os::windows::ffi::OsStrExt; +fn trim_trailing_separators(path: &Path) -> PathBuf { + trim_trailing_separators_lossy(path) +} - let bytes = path.as_os_str().as_encoded_bytes(); - let mut end = bytes.len(); - while end > 1 && bytes[end - 1] == b'\\' { - end -= 1; +/// The Windows half, compiled and tested on every platform so it cannot rot unseen: the +/// `#[cfg(windows)]` arm above is the only caller in a Windows build, and this makes the +/// body reachable from a unix test. +#[cfg_attr(not(windows), allow(dead_code))] // compiled on every platform so a test covers it +fn trim_trailing_separators_lossy(path: &Path) -> PathBuf { + let text = path.to_string_lossy(); + let trimmed = text.trim_end_matches(['\\', '/']); + // A root has nothing to probe: `/`, a bare `C:` after its separator is trimmed, or a + // relative path that is all separators. + if trimmed.is_empty() || trimmed.ends_with(':') { + PathBuf::from(text.as_ref()) + } else { + PathBuf::from(trimmed) } - OsStr::from_encoded_bytes_unchecked(&bytes[..end]).as_ref() } /// Reads the user's overrides: `settings.yaml` first, then the deprecated `settings.json`. @@ -117,7 +130,10 @@ pub fn load_user_settings(config_base_dir: &Path) -> Result, Confi }); } Err(followed) if followed.kind() == std::io::ErrorKind::NotFound => { - let without_trailing = trim_trailing_separators(config_base_dir); + #[cfg(unix)] + let without_trailing: &Path = trim_trailing_separators(config_base_dir); + #[cfg(windows)] + let without_trailing: PathBuf = trim_trailing_separators(config_base_dir); match std::fs::symlink_metadata(without_trailing) { Ok(_) => { // The entry itself exists (a dangling link, or a link with trailing @@ -247,3 +263,34 @@ pub fn load(paths: &SettingsPaths) -> Result { } } } + +#[cfg(test)] +mod trim_tests { + use super::trim_trailing_separators_lossy; + use std::path::{Path, PathBuf}; + + /// The Windows arm's body, exercised on every platform so it cannot rot unseen. + #[test] + fn the_lossy_trim_drops_only_trailing_separators() { + assert_eq!( + trim_trailing_separators_lossy(Path::new("/srv/relay/config/")), + PathBuf::from("/srv/relay/config") + ); + assert_eq!( + trim_trailing_separators_lossy(Path::new("dangling/")), + PathBuf::from("dangling") + ); + assert_eq!( + trim_trailing_separators_lossy(Path::new("/")), + PathBuf::from("/") + ); + assert_eq!( + trim_trailing_separators_lossy(Path::new(r"C:\")), + PathBuf::from(r"C:\") + ); + assert_eq!( + trim_trailing_separators_lossy(Path::new("/srv/relay")), + PathBuf::from("/srv/relay") + ); + } +} From 1a5eac2b36c5f8c65fc76587e7bc16c083fc376f Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 19:36:05 -0400 Subject: [PATCH 29/32] fix(capture): resolve ts-node from the capture tree The documented setup installs the harness dependencies in the scratch capture tree, but `createRequire(import.meta.url)` resolved beside this script in the rewrite tree, where a checkout with no `node_modules` of its own has nothing to find. Resolve from `cwd` instead, which is the tree the setup step installs into and the tree the generator already validates as its working directory. Measured from a fresh `git archive` tree at /tmp/nostream-capture with `pnpm install --frozen-lockfile`: the regenerated cases reproduce the fixture per name, in order, byte-for-byte in content. --- crates/nostream-config/tests/capture/gen-ts-issues.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/nostream-config/tests/capture/gen-ts-issues.mjs b/crates/nostream-config/tests/capture/gen-ts-issues.mjs index 8a5811251..46aba104b 100644 --- a/crates/nostream-config/tests/capture/gen-ts-issues.mjs +++ b/crates/nostream-config/tests/capture/gen-ts-issues.mjs @@ -26,7 +26,10 @@ if (!existsSync(settingsModule) || !existsSync(path.join(cwd, 'resources/default process.exit(2) } -const require = createRequire(import.meta.url) +// Resolve ts-node from the capture tree, not from this script's own directory: the setup +// above installs the dependencies in `cwd`, and a rewrite checkout with no `node_modules` of +// its own has nothing beside this file to resolve. +const require = createRequire(path.join(cwd, 'package.json')) require('ts-node/register/transpile-only') const { loadDefaults, setByPath, validateSettings } = require(settingsModule) @@ -107,7 +110,7 @@ const EXEMPT = { backend_lmdb: { paths: ['eventStore.backend'], reason: - 'D7: the rewrite targets LMDB, so the Rust validator accepts lmdb and rejects values outside {postgres, lmdb}. Path-scoped, not case-scoped.', + "D7: the rewrite's target backend is LMDB, so the Rust validator accepts lmdb and rejects only values outside {postgres, lmdb}. Path-scoped, not case-scoped.", }, } From 38023f2b0c53d8d4b9d55670e1412109b2b98dfb Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 19:36:10 -0400 Subject: [PATCH 30/32] fix(config): parse webhook URLs with the WHATWG URL parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check_webhook_url` accepted any string with a scheme and something after `//`, so it passed addresses the capture's `new URL()` refuses: an out-of-range port (`https://hooks.example:99999/hook`) and a space in the host (`https://bad host/hook`). The capture parses with the WHATWG parser inside `try`, so the Rust check now parses with `url`, which implements the same standard, and keeps the capture's two outcomes: a parsed non-http(s) scheme is 'url must use http or https' and a parse failure is 'url must be a valid URL'. The raw text is passed through untrimmed, as the capture does — the parser strips leading and trailing C0 controls and spaces itself. Both malformed addresses are added to the parity fixture and regenerated from the capture at 242a3001 (case names `target_url_bad_port`, `target_url_bad_host`), so a check cannot pass an address the daemon's URL parser refuses. The other 17 cases regenerate unchanged; the generator's exempt-reason string is aligned with the copy the fixture already carried, so a regeneration reproduces the fixture exactly. Measured: cargo test -p nostream-config --test ts_parity (6 passed), tests/validator.rs including a 65535 boundary case that must still parse. --- Cargo.lock | 1 + Cargo.toml | 1 + crates/nostream-config/Cargo.toml | 1 + crates/nostream-config/src/validate.rs | 40 ++++++---------- .../tests/capture/gen-ts-issues.mjs | 8 ++++ .../tests/fixtures/ts-issues.json | 46 +++++++++++++++++++ crates/nostream-config/tests/validator.rs | 33 +++++++++++++ 7 files changed, 103 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a44ef9956..1f9bfbc8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -768,6 +768,7 @@ dependencies = [ "serde_yaml", "tempfile", "thiserror", + "url", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5734b5fd7..bbe85368f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal tracing = "0.1" tracing-opentelemetry = "0.34" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +url = "2" [workspace.lints.rust] # `deny` rather than `forbid`: heed's LMDB `EnvOpenOptions::open` is an `unsafe fn` diff --git a/crates/nostream-config/Cargo.toml b/crates/nostream-config/Cargo.toml index af696789a..2daeaad40 100644 --- a/crates/nostream-config/Cargo.toml +++ b/crates/nostream-config/Cargo.toml @@ -13,6 +13,7 @@ serde.workspace = true serde_json.workspace = true serde_yaml.workspace = true thiserror.workspace = true +url.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/nostream-config/src/validate.rs b/crates/nostream-config/src/validate.rs index 690cbae94..858f41cf6 100644 --- a/crates/nostream-config/src/validate.rs +++ b/crates/nostream-config/src/validate.rs @@ -8,6 +8,7 @@ use nostream_core::EventStoreBackend; use serde_yaml::Value; +use url::Url; use crate::error::ConfigError; use crate::issue::Issue; @@ -463,35 +464,20 @@ enum UrlCheck { Invalid, } +/// Classifies one notification URL the way the capture does: `new URL(target.url)` inside +/// `try`, then the protocol check on the parsed result. +/// +/// The parse is the WHATWG parser rather than a hand-rolled scan, so the check refuses what +/// the capture refuses — a port outside 1–65535 (`https://hooks.example:99999/hook`), a space +/// in the host (`https://bad host/hook`) — instead of only requiring something after `//`. +/// The parser also strips leading and trailing C0 controls and spaces, as `new URL()` does, +/// so the raw text is passed through untrimmed. fn check_webhook_url(raw: &str) -> UrlCheck { - let trimmed = raw.trim(); - let Some(colon) = trimmed.find(':') else { - return UrlCheck::Invalid; - }; - let scheme = &trimmed[..colon]; - let mut characters = scheme.chars(); - let valid_scheme = matches!(characters.next(), Some(first) if first.is_ascii_alphabetic()) - && characters.all(|character| { - character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.') - }); - if !valid_scheme { - return UrlCheck::Invalid; - } - - let scheme = scheme.to_ascii_lowercase(); - if scheme != "http" && scheme != "https" { - return UrlCheck::BadProtocol; + match Url::parse(raw) { + Ok(parsed) if parsed.scheme() == "http" || parsed.scheme() == "https" => UrlCheck::Ok, + Ok(_) => UrlCheck::BadProtocol, + Err(_) => UrlCheck::Invalid, } - - let Some(authority) = trimmed[colon + 1..].strip_prefix("//") else { - return UrlCheck::Invalid; - }; - let host = authority.split(['/', '?', '#']).next().unwrap_or_default(); - if host.is_empty() { - return UrlCheck::Invalid; - } - - UrlCheck::Ok } /// Validates the typed settings by serializing them back to a document, so there is exactly diff --git a/crates/nostream-config/tests/capture/gen-ts-issues.mjs b/crates/nostream-config/tests/capture/gen-ts-issues.mjs index 46aba104b..e9bef3183 100644 --- a/crates/nostream-config/tests/capture/gen-ts-issues.mjs +++ b/crates/nostream-config/tests/capture/gen-ts-issues.mjs @@ -96,6 +96,14 @@ const CASES = [ name: 'target_bad_url', mutations: [{ op: 'set', path: 'admin.notifications.targets', value: [{ id: 'w1', type: 'http', url: 'ftp://bad.example' }] }], }, + { + name: 'target_url_bad_port', + mutations: [{ op: 'set', path: 'admin.notifications.targets', value: [{ id: 'w1', type: 'http', url: 'https://hooks.example:99999/hook' }] }], + }, + { + name: 'target_url_bad_host', + mutations: [{ op: 'set', path: 'admin.notifications.targets', value: [{ id: 'w1', type: 'discord', url: 'https://bad host/hook' }] }], + }, { name: 'unknown_top_level', mutations: [{ op: 'set', path: 'bogusSection', value: {} }] }, { name: 'unknown_nested_key', mutations: [{ op: 'set', path: 'info.unknownKey', value: 1 }] }, { name: 'wrong_type_workers_count', mutations: [{ op: 'set', path: 'workers.count', value: 'four' }] }, diff --git a/crates/nostream-config/tests/fixtures/ts-issues.json b/crates/nostream-config/tests/fixtures/ts-issues.json index c434abe6f..fa95e4364 100644 --- a/crates/nostream-config/tests/fixtures/ts-issues.json +++ b/crates/nostream-config/tests/fixtures/ts-issues.json @@ -179,6 +179,52 @@ ], "rust_exempt_paths": [] }, + { + "name": "target_url_bad_port", + "mutations": [ + { + "op": "set", + "path": "admin.notifications.targets", + "value": [ + { + "id": "w1", + "type": "http", + "url": "https://hooks.example:99999/hook" + } + ] + } + ], + "ts_issues": [ + { + "path": "admin.notifications.targets[0].url", + "message": "url must be a valid URL" + } + ], + "rust_exempt_paths": [] + }, + { + "name": "target_url_bad_host", + "mutations": [ + { + "op": "set", + "path": "admin.notifications.targets", + "value": [ + { + "id": "w1", + "type": "discord", + "url": "https://bad host/hook" + } + ] + } + ], + "ts_issues": [ + { + "path": "admin.notifications.targets[0].url", + "message": "url must be a valid URL" + } + ], + "rust_exempt_paths": [] + }, { "name": "unknown_top_level", "mutations": [{ "op": "set", "path": "bogusSection", "value": {} }], diff --git a/crates/nostream-config/tests/validator.rs b/crates/nostream-config/tests/validator.rs index 512e34db4..ff897a7bb 100644 --- a/crates/nostream-config/tests/validator.rs +++ b/crates/nostream-config/tests/validator.rs @@ -584,6 +584,39 @@ fn rejects_a_url_that_does_not_parse() { ); } +#[test] +fn rejects_a_webhook_url_with_an_out_of_range_port() { + // The capture parses with `new URL()`, which refuses a port above 65535; a check that + // only required something after `//` used to accept this address. + let document = notifications( + " targets:\n - id: w1\n type: http\n url: https://hooks.example:99999/hook\n", + ); + assert_eq!( + issues(&document), + vec!["admin.notifications.targets[0].url: url must be a valid URL"] + ); +} + +#[test] +fn rejects_a_webhook_url_with_a_space_in_the_host() { + let document = notifications( + " targets:\n - id: w1\n type: discord\n url: https://bad host/hook\n", + ); + assert_eq!( + issues(&document), + vec!["admin.notifications.targets[0].url: url must be a valid URL"] + ); +} + +#[test] +fn accepts_a_webhook_url_with_a_high_but_valid_port() { + // The boundary the previous case sits just outside of: 65535 parses. + let document = notifications( + " targets:\n - id: w1\n type: http\n url: https://hooks.example:65535/hook\n", + ); + assert_eq!(issues(&document), Vec::::new()); +} + #[test] fn accepts_a_well_formed_http_target() { let document = notifications( From 1d954a29ea9761dd1efd3caf59acbc01a6b23684 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 19:36:15 -0400 Subject: [PATCH 31/32] fix(config): refuse what the loader cannot read or cannot hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two P1s on the same seam — the loader's two paths disagreeing with what it reports. A settings path that exists but is not a regular file now reads as a finding, not as absence. `is_file()` answered false for a directory named `settings.yaml`, so the loader fell through to the deprecated `settings.json` or the shipped defaults and `--check` approved the result while the operator's real file was unusable. The capture `existsSync`-es the path and then fails on the read; `probe_settings_file` mirrors that: a non-file is `ConfigError::NotAFile`, a dangling symlink still falls through (the capture's `existsSync` is false there too), and an inspection the process cannot perform is loud. The same probe covers the legacy `settings.json`. A document the typed schema cannot represent is now refused with the field named. `validate_document` grades JavaScript types, where every number is a `number`, so a rate-limit `period` of `1000.5` was a positive number to the validator and unrepresentable to `u64` — `config validate` approved it, the daemon then discarded the operator's whole configuration for the shipped defaults, and the finding said only `$`. `fit` narrows a validated document and, on failure, walks the typed defaults (serialized back, so a `u64` stays an integer and an `f64` becomes a float) to name the first field whose number the type cannot hold. The daemon's `load` and the CLI's `config validate` both call it, so neither can approve what the other cannot load. Messages keep carrying the class, never the operator's value. Measured: cargo test --workspace --all-features (153 passed, 0 failed), clippy with -D warnings clean, fmt clean. End to end through the real binary: `config validate` on `periodMs: 1000.5` exits 2 with 'limits.event.pow.periodMs: must be a whole number'; `nostreamd --check` on the same file exits 2 with the field in stderr; `--check` with `settings.yaml` as a directory exits non-zero and names the path, where it used to print 'settings valid: 0 issues'. --- crates/nostream-cli/src/config.rs | 14 ++- crates/nostream-config/src/error.rs | 5 + crates/nostream-config/src/lib.rs | 2 +- crates/nostream-config/src/load.rs | 155 +++++++++++++++++++++++---- crates/nostream-config/tests/load.rs | 90 +++++++++++++++- crates/nostreamd/tests/check_flag.rs | 40 +++++++ 6 files changed, 281 insertions(+), 25 deletions(-) diff --git a/crates/nostream-cli/src/config.rs b/crates/nostream-cli/src/config.rs index 557a6d1f2..33becf63c 100644 --- a/crates/nostream-cli/src/config.rs +++ b/crates/nostream-cli/src/config.rs @@ -8,7 +8,8 @@ use std::process::ExitCode; use nostream_config::path::get_by_path; use nostream_config::{ - SettingsPaths, load, load_defaults, load_document, load_user_settings, merge, validate_document, + SettingsPaths, fit, load, load_defaults, load_document, load_user_settings, merge, + validate_document, }; use serde_yaml::Value; @@ -55,7 +56,16 @@ pub fn validate( None => defaults.clone(), }; - let issues = validate_document(&merged, &defaults); + let mut issues = validate_document(&merged, &defaults); + if issues.is_empty() { + // The document walk grades JavaScript types; the typed schema is stricter. A document + // the walk approves but the daemon cannot load (a fractional integer, for one) is + // refused here, with the field named, so `config validate` never blesses a config the + // daemon will discard. + if let Err(issue) = fit(&merged, &defaults) { + issues.push(issue); + } + } if issues.is_empty() { return Ok(CommandOutput { lines: vec!["settings valid: 0 issues".to_owned()], diff --git a/crates/nostream-config/src/error.rs b/crates/nostream-config/src/error.rs index 568911633..052c021dd 100644 --- a/crates/nostream-config/src/error.rs +++ b/crates/nostream-config/src/error.rs @@ -44,6 +44,11 @@ pub enum ConfigError { #[error("settings path {path} exists but is not a directory")] NotADirectory { path: std::path::PathBuf }, + /// A settings file path exists but is not a regular file (a directory, a FIFO, a device). + /// A finding, not absence: the capture `existsSync`-es the path and then fails on the read. + #[error("settings file {path} exists but is not a regular file")] + NotAFile { path: std::path::PathBuf }, + /// The configured settings location could not be stat-ed (unreadable parent, symlink /// loop, permission denied) — treated as a finding, not as absence. #[error("settings path {path} could not be inspected: {source}")] diff --git a/crates/nostream-config/src/lib.rs b/crates/nostream-config/src/lib.rs index a64b87370..aee6f227b 100644 --- a/crates/nostream-config/src/lib.rs +++ b/crates/nostream-config/src/lib.rs @@ -33,7 +33,7 @@ pub mod value; pub use error::ConfigError; pub use issue::Issue; -pub use load::{LoadOutcome, load, load_defaults, load_document, load_user_settings, merge}; +pub use load::{LoadOutcome, fit, load, load_defaults, load_document, load_user_settings, merge}; pub use paths::SettingsPaths; pub use settings::Settings; pub use validate::{validate, validate_document}; diff --git a/crates/nostream-config/src/load.rs b/crates/nostream-config/src/load.rs index d00fc4b8c..fc765040f 100644 --- a/crates/nostream-config/src/load.rs +++ b/crates/nostream-config/src/load.rs @@ -13,6 +13,7 @@ use serde_yaml::Value; use crate::error::ConfigError; use crate::issue::Issue; +use crate::path::{PathToken, format_path_tokens}; use crate::paths::SettingsPaths; use crate::settings::Settings; use crate::validate::validate_document; @@ -109,6 +110,9 @@ fn trim_trailing_separators_lossy(path: &Path) -> PathBuf { /// Reads the user's overrides: `settings.yaml` first, then the deprecated `settings.json`. /// /// Returns `None` when neither file exists. An empty YAML file is an empty override set. +/// A path that exists but is not a regular file is an error ([`ConfigError::NotAFile`]), +/// not absence: falling through would hide an unusable `settings.yaml` behind the legacy +/// file or the defaults. /// /// The directory is created only by the mutating callers (the guided editor, the save path); /// a read probe reports `Ok(None)` for an absent directory instead of materialising one — @@ -162,7 +166,7 @@ pub fn load_user_settings(config_base_dir: &Path) -> Result, Confi } let settings_file = config_base_dir.join("settings.yaml"); - if settings_file.is_file() { + if probe_settings_file(&settings_file)? { let document = load_document(&settings_file)?; return Ok(Some(if document.is_null() { Value::Mapping(serde_yaml::Mapping::new()) @@ -172,7 +176,7 @@ pub fn load_user_settings(config_base_dir: &Path) -> Result, Confi } let legacy_file = config_base_dir.join("settings.json"); - if legacy_file.is_file() { + if probe_settings_file(&legacy_file)? { let document = load_document(&legacy_file)?; return Ok(Some(if document.is_null() { Value::Mapping(serde_yaml::Mapping::new()) @@ -184,11 +188,134 @@ pub fn load_user_settings(config_base_dir: &Path) -> Result, Confi Ok(None) } +/// Classifies one settings file path: `true` reads it, `false` moves to the next candidate. +/// +/// The capture decides with `fs.existsSync(settingsPath)` and then reads unconditionally, so +/// a path that exists but is not a regular file (a directory named `settings.yaml`, a FIFO) +/// is a *failure*, not absence — treating it as absent would fall through to the deprecated +/// `settings.json` or to the shipped defaults and let `--check` report a clean config while +/// the operator's real file is unusable. Only a path that does not exist falls through; a +/// dangling symlink reports `existsSync` false in the capture too, so it also falls through. +/// An inspection the process cannot perform at all (EACCES on the parent, ELOOP) is loud. +fn probe_settings_file(path: &Path) -> Result { + match fs::metadata(path) { + Ok(metadata) if metadata.is_file() => Ok(true), + Ok(_) => Err(ConfigError::NotAFile { + path: path.to_path_buf(), + }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(source) => Err(ConfigError::StatSettingsPath { + path: path.to_path_buf(), + source, + }), + } +} + /// Deep-merges user overrides over the defaults (`mergeDeepRight`). pub fn merge(defaults: &Value, user: &Value) -> Value { merge_deep_right(defaults, user) } +/// Narrows a validated document to [`Settings`], naming the field a value cannot fit. +/// +/// `validate_document` grades a document the way the capture does, where every number is a +/// JavaScript `number`; the typed schema distinguishes `u64`, `i64` and `f64`. A rate-limit +/// `period` of `1000.5` is therefore a positive number to the validator and unrepresentable +/// to [`crate::settings::RateLimit::period`], so validating alone could approve a document +/// the daemon then refuses — and the refusal replaces the operator's whole configuration +/// with the shipped defaults. Both the daemon and `config validate` call this, so neither +/// can approve what the other cannot load. +/// +/// The schema walk runs only when the conversion fails: the typed defaults are serialized +/// back to a document — where a `u64` stays an integer and an `f64` becomes a float — and +/// the first leaf whose number the candidate cannot represent names the field. Messages +/// carry the class, never the operator's value. +pub fn fit(candidate: &Value, defaults: &Value) -> Result { + match serde_yaml::from_value::(candidate.clone()) { + Ok(settings) => Ok(settings), + Err(error) => { + let projection = serde_yaml::from_value::(defaults.clone()) + .ok() + .and_then(|typed| serde_yaml::to_value(typed).ok()); + if let Some(projection) = projection + && let Some(issue) = find_misfit(&projection, candidate, &mut Vec::new()) + { + return Err(issue); + } + // Nothing the numeric walk can name: report the class, and the parse location + // when serde exposes one — never the value, which would echo into logs. + let location = error.location().map_or_else(String::new, |location| { + format!(" at line {} column {}", location.line(), location.column()) + }); + Err(Issue::new( + "$", + format!("settings do not fit the typed schema{location}"), + )) + } + } +} + +/// Walks the typed projection against the candidate, returning the first leaf whose number +/// the field's type cannot represent. Only numbers are compared: every other mismatch is +/// already an issue from `validate_document`'s shape walk. +/// +/// The projection cannot tell a `u32` field from an `i64` one — serde_yaml marks any +/// non-negative integer as a positive integer — so a value too large for an `i64` field falls +/// through to [`fit`]'s class-level finding rather than being named here. +fn find_misfit(schema: &Value, candidate: &Value, path: &mut Vec) -> Option { + match (schema, candidate) { + (Value::Mapping(schema), Value::Mapping(candidate)) => { + for (key, candidate_value) in candidate { + let Some(key) = key.as_str() else { + continue; + }; + let Some(schema_value) = schema.get(key) else { + continue; + }; + path.push(PathToken::Key(key.to_owned())); + let misfit = find_misfit(schema_value, candidate_value, path); + path.pop(); + if misfit.is_some() { + return misfit; + } + } + None + } + (Value::Sequence(schema), Value::Sequence(candidate)) => { + // A `Vec` has one element type, so the first default entry is the schema for + // every element; a user's list may be longer or shorter than the defaults'. + let schema_entry = schema.first()?; + for (index, candidate_value) in candidate.iter().enumerate() { + path.push(PathToken::Index(index)); + let misfit = find_misfit(schema_entry, candidate_value, path); + path.pop(); + if misfit.is_some() { + return misfit; + } + } + None + } + (Value::Number(schema_number), Value::Number(candidate_number)) => { + let message = if schema_number.is_f64() { + // Any number the document can carry fits an `f64` field. + return None; + } else if schema_number.is_u64() && !candidate_number.is_u64() { + if candidate_number.as_i64().is_some() { + "must not be negative" + } else { + "must be a whole number" + } + } else if schema_number.is_i64() && candidate_number.as_i64().is_none() { + "must fit a signed 64-bit integer" + } else { + return None; + }; + Some(Issue::new(format_path_tokens(path), message)) + } + _ => None, + } +} + /// The result of loading the effective settings. #[derive(Debug, Clone)] pub struct LoadOutcome { @@ -238,29 +365,17 @@ pub fn load(paths: &SettingsPaths) -> Result { }); } - match serde_yaml::from_value::(merged) { + match fit(&merged, &defaults) { Ok(settings) => Ok(LoadOutcome { settings, issues, used_defaults: false, }), - Err(error) => { - // serde's message renders the offending scalar (e.g. an out-of-i64 integer the - // shape walk cannot see), which would echo an operator-supplied value into - // stderr and logs. Report the class, not the value; the parse location rides - // along when serde exposes one. - let location = error.location().map_or_else(String::new, |location| { - format!(" at line {} column {}", location.line(), location.column()) - }); - Ok(LoadOutcome { - settings: defaults_settings, - issues: vec![Issue::new( - "$", - format!("settings do not fit the typed schema{location}"), - )], - used_defaults: true, - }) - } + Err(issue) => Ok(LoadOutcome { + settings: defaults_settings, + issues: vec![issue], + used_defaults: true, + }), } } diff --git a/crates/nostream-config/tests/load.rs b/crates/nostream-config/tests/load.rs index 2c6dad2d6..637d41725 100644 --- a/crates/nostream-config/tests/load.rs +++ b/crates/nostream-config/tests/load.rs @@ -5,9 +5,9 @@ use std::fs; use std::path::{Path, PathBuf}; -use nostream_config::SettingsPaths; -use nostream_config::load::{load, load_user_settings, merge}; +use nostream_config::load::{fit, load, load_user_settings, merge}; use nostream_config::path::get_by_path; +use nostream_config::{ConfigError, SettingsPaths, load_defaults, validate_document}; use serde_yaml::Value; use tempfile::TempDir; @@ -326,3 +326,89 @@ fn a_relative_single_component_dangling_link_with_a_trailing_separator_is_a_find assert_eq!(outcome.issues.len(), 1); assert!(outcome.issues[0].to_string().contains("not a directory")); } + +#[test] +fn a_fractional_integer_field_is_refused_by_validation_and_by_the_daemon_alike() { + // R8's agreement rule, and the finding that produced it: `period` is a JavaScript number + // to the capture's validator (positive, therefore fine) and a `u64` to the typed schema, + // so approving on the walk alone let `config validate` bless a document the daemon then + // rejected — replacing the operator's whole configuration with the shipped defaults. + let config_dir = TempDir::new().expect("tempdir"); + write( + config_dir.path(), + "settings.yaml", + "limits:\n event:\n pow:\n periodMs: 1000.5\n", + ); + let settings_paths = paths(config_dir.path()); + + // Path 1, the daemon's: `load` reports the field and substitutes the defaults. + let outcome = load(&settings_paths).expect("the loader never refuses"); + assert!(outcome.used_defaults); + assert_eq!( + outcome + .issues + .iter() + .map(|issue| issue.to_string()) + .collect::>(), + vec!["limits.event.pow.periodMs: must be a whole number"], + ); + + // Path 2, `config validate`'s: the document walk on its own still approves (it mirrors + // the capture), and `fit` — which the CLI now runs — rejects the same document at the + // same field, so the two paths agree. + let defaults = load_defaults(&settings_paths.default_settings_file).expect("defaults"); + let user = load_user_settings(config_dir.path()) + .expect("read") + .expect("present"); + let merged = merge(&defaults, &user); + assert_eq!(validate_document(&merged, &defaults), Vec::new()); + let issue = fit(&merged, &defaults).expect_err("the typed schema refuses"); + assert_eq!( + issue.to_string(), + "limits.event.pow.periodMs: must be a whole number" + ); +} + +#[test] +fn a_negative_integer_field_names_the_field() { + // No semantic rule covers `workers.count`, so the document walk is silent and only the + // typed fit can refuse it — which is the point: the field is named rather than the whole + // document being reported at `$`. + let defaults: Value = + load_defaults(&repo_root().join("resources/default-settings.yaml")).expect("defaults"); + let merged = merge( + &defaults, + &serde_yaml::from_str::("workers:\n count: -1\n").expect("document"), + ); + assert_eq!(validate_document(&merged, &defaults), Vec::new()); + let issue = fit(&merged, &defaults).expect_err("the typed schema refuses"); + assert_eq!(issue.to_string(), "workers.count: must not be negative"); +} + +#[test] +fn a_settings_yaml_directory_is_a_finding_not_absence() { + // `is_file()` used to answer false here, so the loader fell through to the deprecated + // `settings.json` or the defaults and `--check` reported a clean config while the + // operator's real file was unusable. + let config_dir = TempDir::new().expect("tempdir"); + fs::create_dir(config_dir.path().join("settings.yaml")).expect("a directory at the path"); + + let error = load_user_settings(config_dir.path()).expect_err("a directory is reported"); + assert!(matches!(error, ConfigError::NotAFile { .. })); + assert!(error.to_string().contains("not a regular file")); + + let outcome = load(&paths(config_dir.path())).expect("the defaults still load"); + assert!(outcome.used_defaults); + assert_eq!(outcome.issues.len(), 1); + assert_eq!(outcome.issues[0].path, "$"); + assert!(outcome.issues[0].message.contains("settings.yaml")); +} + +#[test] +fn a_legacy_json_directory_is_reported_the_same_way() { + let config_dir = TempDir::new().expect("tempdir"); + fs::create_dir(config_dir.path().join("settings.json")).expect("a directory at the path"); + + let error = load_user_settings(config_dir.path()).expect_err("a directory is reported"); + assert!(matches!(error, ConfigError::NotAFile { .. })); +} diff --git a/crates/nostreamd/tests/check_flag.rs b/crates/nostreamd/tests/check_flag.rs index 0ba327cc1..8be983834 100644 --- a/crates/nostreamd/tests/check_flag.rs +++ b/crates/nostreamd/tests/check_flag.rs @@ -84,3 +84,43 @@ fn check_exits_two_on_an_invalid_config() { assert_eq!(code, Some(2), "exit code"); assert!(stderr.contains("strategy must be ewma or sliding_window")); } + +#[test] +fn check_exits_two_on_a_value_the_typed_schema_cannot_hold() { + // `config validate` and the daemon must agree: a fractional integer is a positive number + // to the document walk and unrepresentable to the typed schema, so it is refused with the + // field named instead of being approved here and discarded at load. + let directory = tempfile::tempdir().expect("tempdir"); + std::fs::write( + directory.path().join("settings.yaml"), + "limits:\n event:\n pow:\n periodMs: 1000.5\n", + ) + .expect("write"); + + let config_dir = directory.path().to_string_lossy().into_owned(); + let (code, _stdout, stderr) = run_check( + Duration::from_secs(30), + &["--check", "--config-dir", &config_dir], + ); + assert_eq!(code, Some(2), "exit code"); + assert!( + stderr.contains("limits.event.pow.periodMs"), + "stderr: {stderr}" + ); +} + +#[test] +fn check_refuses_a_settings_yaml_that_is_a_directory() { + // A path that exists but is not a regular file used to read as absence, so `--check` + // approved the defaults while the operator's real file was unusable. + let directory = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir(directory.path().join("settings.yaml")).expect("a directory at the path"); + + let config_dir = directory.path().to_string_lossy().into_owned(); + let (code, _stdout, stderr) = run_check( + Duration::from_secs(30), + &["--check", "--config-dir", &config_dir], + ); + assert_ne!(code, Some(0), "a directory must not read as a clean config"); + assert!(stderr.contains("not a regular file"), "stderr: {stderr}"); +} From 54e651412732f8f7301810b8ad0d113b0e165509 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 17 Oct 2026 20:57:59 -0400 Subject: [PATCH 32/32] fix(config): name the period, not the valid rate, when both are fractional `find_misfit` reads each field's type off the typed defaults serialized back to a document, and `serialize_js_number` writes an integral `f64` as the integer `50` - correct for the document, and lossy for the walk. `targetEventsPerSecond` is an `f64`, so it looked like an integer field and was named first: limits.event.pow.targetEventsPerSecond: must be a whole number for a document whose actual fault was `periodMs: 1000.5`. Operators were sent to a setting that is fine while the bad one went unreported. Every `f64` in `settings.rs` now lives in `FLOAT_FIELDS`, and `force_floats` restores each declared type before the walk: the projection cannot be asked for types it does not carry, so the list is the source of truth and a guard test pins its length and names the one entry the shipped defaults cannot verify (`wotThresholds[]`, which they do not carry). Measured with `nostream-cli config validate` on `targetEventsPerSecond: 10.5` + `periodMs: 1000.5`: before limits.event.pow.targetEventsPerSecond: must be a whole number after limits.event.pow.periodMs: must be a whole number The rate alone still validates (exit 0), so a legitimate `f64` is not refused. Regression proof: with `force_floats` removed, `a_fractional_rate_blames_the_period_not_the_rate` fails on exactly the two strings above. Workspace: 157 pass / 0 fail, clippy -D warnings clean, fmt clean. --- .changeset/rust-foundation-005.md | 9 ++ crates/nostream-config/src/load.rs | 67 ++++++++++++- crates/nostream-config/src/settings.rs | 11 +++ crates/nostream-config/tests/load.rs | 125 +++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 4 deletions(-) diff --git a/.changeset/rust-foundation-005.md b/.changeset/rust-foundation-005.md index a845151cc..4c7760232 100644 --- a/.changeset/rust-foundation-005.md +++ b/.changeset/rust-foundation-005.md @@ -1,2 +1,11 @@ --- --- + +fix(config): name the field whose number does not fit, not a valid neighbour + +The loader's numeric walk read a field's type off the serialized defaults, where +`serialize_js_number` writes an integral `f64` as an integer because that is how +JavaScript represents it. So a valid `targetEventsPerSecond: 10.5` beside a +fractional `periodMs` was refused as "must be a whole number" while the actual +bad field went unreported. Every `f64` the schema declares is now restored to its +declared type before the walk runs, and a guard test keeps that list complete. diff --git a/crates/nostream-config/src/load.rs b/crates/nostream-config/src/load.rs index fc765040f..985cdb554 100644 --- a/crates/nostream-config/src/load.rs +++ b/crates/nostream-config/src/load.rs @@ -230,6 +230,12 @@ pub fn merge(defaults: &Value, user: &Value) -> Value { /// back to a document — where a `u64` stays an integer and an `f64` becomes a float — and /// the first leaf whose number the candidate cannot represent names the field. Messages /// carry the class, never the operator's value. +/// +/// The serialized projection cannot say what type an `f64` field *is*: `serialize_js_number` +/// writes an integral `f64` as the integer `50`, because that is how JavaScript represents +/// it and how the document reads back. So every leaf [`crate::settings::FLOAT_FIELDS`] names +/// is restored to `f64` before the walk runs, which is what stops a valid +/// `targetEventsPerSecond: 10.5` beside a fractional `periodMs` from blaming the rate. pub fn fit(candidate: &Value, defaults: &Value) -> Result { match serde_yaml::from_value::(candidate.clone()) { Ok(settings) => Ok(settings), @@ -237,10 +243,12 @@ pub fn fit(candidate: &Value, defaults: &Value) -> Result { let projection = serde_yaml::from_value::(defaults.clone()) .ok() .and_then(|typed| serde_yaml::to_value(typed).ok()); - if let Some(projection) = projection - && let Some(issue) = find_misfit(&projection, candidate, &mut Vec::new()) - { - return Err(issue); + if let Some(projection) = projection { + let mut schema = projection.clone(); + force_floats(&mut schema, &projection); + if let Some(issue) = find_misfit(&schema, candidate, &mut Vec::new()) { + return Err(issue); + } } // Nothing the numeric walk can name: report the class, and the parse location // when serde exposes one — never the value, which would echo into logs. @@ -255,6 +263,57 @@ pub fn fit(candidate: &Value, defaults: &Value) -> Result { } } +/// Restores the declared type of every `f64` leaf the projection lost to [`serialize_js_number`]. +/// +/// Each path in [`crate::settings::FLOAT_FIELDS`] that the projection carries is replaced by +/// the default's own `f64` value, so [`find_misfit`] sees the field's type rather than the +/// type its default happened to serialize as. Paths the projection does not reach — a list +/// shorter than the defaults' — are left alone; the walk reads a list's schema from its first +/// default element, so the element type survives either way. +fn force_floats(schema: &mut Value, projection: &Value) { + for path in crate::settings::FLOAT_FIELDS { + let Some(default) = lookup(projection, path).and_then(Value::as_f64) else { + continue; + }; + if let Some(leaf) = lookup_mut(schema, path) { + *leaf = Value::from(default); + } + } +} + +/// Resolves a dotted path through mappings. A segment ending in `[]` (`wotThresholds[]`) +/// descends into its first element, since [`find_misfit`] reads a list's schema from there. +fn lookup<'a>(value: &'a Value, path: &str) -> Option<&'a Value> { + path.split('.').try_fold(value, |node, segment| { + let (key, element) = match segment.strip_suffix("[]") { + Some(key) => (key, true), + None => (segment, false), + }; + let node = node.get(key)?; + if element { + node.as_sequence().and_then(|sequence| sequence.first()) + } else { + Some(node) + } + }) +} + +fn lookup_mut<'a>(value: &'a mut Value, path: &str) -> Option<&'a mut Value> { + path.split('.').try_fold(value, |node, segment| { + let (key, element) = match segment.strip_suffix("[]") { + Some(key) => (key, true), + None => (segment, false), + }; + let node = node.get_mut(key)?; + if element { + node.as_sequence_mut() + .and_then(|sequence| sequence.first_mut()) + } else { + Some(node) + } + }) +} + /// Walks the typed projection against the candidate, returning the first leaf whose number /// the field's type cannot represent. Only numbers are compared: every other mismatch is /// already an issue from `validate_document`'s shape walk. diff --git a/crates/nostream-config/src/settings.rs b/crates/nostream-config/src/settings.rs index 8e21d735c..77b09eb5e 100644 --- a/crates/nostream-config/src/settings.rs +++ b/crates/nostream-config/src/settings.rs @@ -16,6 +16,17 @@ use serde::{Deserialize, Serialize}; use crate::notifications::AdminNotificationsSettings; +/// The paths [`Settings`] declares as `f64`. +/// +/// A serialized document cannot say which leaves are floats: [`serialize_js_number`] writes +/// an integral `f64` as the integer `50`, which is the right encoding and loses the type. The +/// loader's numeric walk needs the type to name the field a value does not fit, so every +/// `f64` in this module is listed here and a test walks the schema to keep the list complete. +pub const FLOAT_FIELDS: &[&str] = &[ + "limits.event.pow.targetEventsPerSecond", + "limits.event.pow.wotThresholds[].difficultyFactor", +]; + /// Serializes a JavaScript `number` as an integer when it is integral, so a document that /// wrote `50` round-trips as `50` rather than `50.0`. YAML distinguishes the two even though /// JavaScript does not. diff --git a/crates/nostream-config/tests/load.rs b/crates/nostream-config/tests/load.rs index 637d41725..597130cc4 100644 --- a/crates/nostream-config/tests/load.rs +++ b/crates/nostream-config/tests/load.rs @@ -369,6 +369,86 @@ fn a_fractional_integer_field_is_refused_by_validation_and_by_the_daemon_alike() ); } +#[test] +fn a_fractional_rate_blames_the_period_not_the_rate() { + // `targetEventsPerSecond` is an `f64` and `periodMs` a `u64`, and the walk reads the + // field's type off the serialized projection - where `serialize_js_number` writes the + // default rate `50` as the integer `50`, because JavaScript has one number type. So the + // valid rate was taken for an integer field and named first, sending the operator to a + // setting that is fine while the bad period went unreported. + let defaults: Value = + load_defaults(&repo_root().join("resources/default-settings.yaml")).expect("defaults"); + let merged = merge( + &defaults, + &serde_yaml::from_str::("limits:\n event:\n pow:\n targetEventsPerSecond: 10.5\n periodMs: 1000.5\n") + .expect("document"), + ); + assert_eq!(validate_document(&merged, &defaults), Vec::new()); + let issue = fit(&merged, &defaults).expect_err("the typed schema refuses"); + assert_eq!( + issue.to_string(), + "limits.event.pow.periodMs: must be a whole number" + ); +} + +#[test] +fn a_fractional_difficulty_factor_is_not_mistaken_for_an_integer_field() { + // `difficultyFactor` is an `f64` inside a list, and `serialize_js_number` writes a `0` + // default as the integer `0` - so the element type has to survive the walk too, not just + // the top-level scalars. + let defaults: Value = + load_defaults(&repo_root().join("resources/default-settings.yaml")).expect("defaults"); + let merged = merge( + &defaults, + &serde_yaml::from_str::( + "limits:\n event:\n pow:\n targetEventsPerSecond: 10.5\n wotThresholds:\n - maxDistance: 1\n difficultyFactor: 0.25\n", + ) + .expect("document"), + ); + assert_eq!(validate_document(&merged, &defaults), Vec::new()); + let settings = fit(&merged, &defaults).expect("a fractional factor and rate both fit"); + let pow = settings + .limits + .expect("limits") + .event + .expect("event limits") + .pow + .expect("pow"); + assert_eq!(pow.target_events_per_second, 10.5); + assert_eq!( + pow.wot_thresholds.expect("thresholds")[0].difficulty_factor, + 0.25 + ); +} + +#[test] +fn a_fractional_rate_is_accepted() { + // The other half of the same confusion: a legitimate `f64` value must not be refused, + // and only the explicit `f64` leaves say so once the projection writes it as an integer. + let defaults: Value = + load_defaults(&repo_root().join("resources/default-settings.yaml")).expect("defaults"); + let merged = merge( + &defaults, + &serde_yaml::from_str::( + "limits:\n event:\n pow:\n targetEventsPerSecond: 10.5\n", + ) + .expect("document"), + ); + assert_eq!(validate_document(&merged, &defaults), Vec::new()); + let settings = fit(&merged, &defaults).expect("a fractional rate fits"); + assert_eq!( + settings + .limits + .expect("limits") + .event + .expect("event limits") + .pow + .expect("pow") + .target_events_per_second, + 10.5 + ); +} + #[test] fn a_negative_integer_field_names_the_field() { // No semantic rule covers `workers.count`, so the document walk is silent and only the @@ -412,3 +492,48 @@ fn a_legacy_json_directory_is_reported_the_same_way() { let error = load_user_settings(config_dir.path()).expect_err("a directory is reported"); assert!(matches!(error, ConfigError::NotAFile { .. })); } + +#[test] +fn every_float_field_is_declared_to_the_numeric_walk() { + // The walk reads a field's type off the serialized defaults, where `serialize_js_number` + // has already turned an integral `f64` into an integer - so a new `f64` that does not + // join `FLOAT_FIELDS` is silently treated as an integer field, and a valid fractional + // value beside it is what gets blamed. This is the list's only guard: the schema cannot + // be asked for the types, because the document has none. + let defaults: Value = + load_defaults(&repo_root().join("resources/default-settings.yaml")).expect("defaults"); + // A path the shipped defaults do not carry cannot be checked here - `wotThresholds` is + // absent, so its element type rests on the list alone. Those paths are named, not skipped + // silently, so the gap is visible instead of reading as coverage. + let unverified: Vec<_> = nostream_config::settings::FLOAT_FIELDS + .iter() + .filter(|path| resolve(&defaults, path).is_none()) + .collect(); + assert_eq!( + unverified, + vec![&"limits.event.pow.wotThresholds[].difficultyFactor"], + "an unreachable FLOAT_FIELDS path is a stale entry, and a newly added one has no default to check against" + ); + assert_eq!( + nostream_config::settings::FLOAT_FIELDS.len(), + 2, + "a new `f64` field in `settings.rs` needs a path in `FLOAT_FIELDS` and this count" + ); +} + +/// Resolves a dotted path whose `[]` segment means "the first element", the same convention +/// `FLOAT_FIELDS` uses. +fn resolve<'a>(value: &'a Value, path: &str) -> Option<&'a Value> { + path.split('.').try_fold(value, |node, segment| { + let (key, element) = match segment.strip_suffix("[]") { + Some(key) => (key, true), + None => (segment, false), + }; + let node = node.get(key)?; + if element { + node.as_sequence().and_then(|sequence| sequence.first()) + } else { + Some(node) + } + }) +}