Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
d827c97
feat(relay): bound the total queued frames and the sockets one addres…
chappie-daemon Oct 25, 2026
7f16351
docs(rewrite): record the frame-memory budget and the per-IP cap (ADR…
chappie-daemon Oct 25, 2026
bb71b72
docs(relay): name the two new knobs where RelayConfig's list is, and …
chappie-daemon Oct 25, 2026
74ff50d
docs(rewrite): correct ADR 0008's line citations to the final tree
chappie-daemon Oct 25, 2026
8ae2009
fix(relay): wake the connection loop on a refusal, and encode only fr…
chappie-daemon Oct 25, 2026
c5bc366
docs(rewrite): record the refusal signal in ADR 0008 and retarget its…
chappie-daemon Oct 25, 2026
1479c6c
fix(relay): stop a stored read at the first refused frame, and correc…
chappie-daemon Oct 25, 2026
f688ae4
docs(rewrite): point ADR 0008 at the cap's removal block, not the bla…
chappie-daemon Oct 25, 2026
5b23eeb
fix(relay): hold a refused read until the close retires it, so the wa…
chappie-daemon Oct 25, 2026
ff144dc
docs(rewrite): say that a refused read waits for the close, and what …
chappie-daemon Oct 25, 2026
f67e726
docs(rewrite): record the park's test-power trade the right way round
chappie-daemon Oct 25, 2026
04186d4
docs(rewrite): say that the abort, not the watch, retires a refused read
chappie-daemon Oct 25, 2026
82ade58
feat(config): expose the frame budget and per-IP cap as settings keys
chappie-daemon Oct 25, 2026
717e4cb
docs(rewrite): record the two bounds as operator settings, and what a…
chappie-daemon Oct 25, 2026
47fc464
test(relay): prove from_settings reads the operator's bounds
chappie-daemon Oct 25, 2026
d07f898
fix(relay): cap a setting's bound so an absurd value cannot exhaust u…
chappie-daemon Oct 25, 2026
03180b8
docs(rewrite): say what a bound above the ceiling does, and what the …
chappie-daemon Oct 25, 2026
645a85b
fix(daemon): report a bound setting the relay will replace with its d…
chappie-daemon Oct 25, 2026
99b78a7
docs(rewrite): re-anchor every citation, and name the ceiling where t…
chappie-daemon Oct 25, 2026
593eac8
fix(daemon): tell a zero bound from one above the ceiling, on both re…
chappie-daemon Oct 25, 2026
fdc9b22
feat(config): name the cap's unit in its guided label
chappie-daemon Oct 25, 2026
78ef4cf
docs(rewrite): say what the skip does, and re-anchor the citations it…
chappie-daemon Oct 25, 2026
3257f01
fix(relay): derive the refusal from the range check and word it once
chappie-daemon Oct 25, 2026
0679ea1
docs(rewrite): say what a rejected document leaves to report, and re-…
chappie-daemon Oct 25, 2026
d3416ba
fix(daemon): never describe a substituted document as a bound violation
chappie-daemon Oct 25, 2026
60aa7e9
docs(rewrite): state the two mechanisms a rejected document leaves, a…
chappie-daemon Oct 25, 2026
3eaf2c8
fix(config): refuse a zero bound in the validator instead of reading …
chappie-daemon Oct 25, 2026
8d7f9ce
docs(config): state the bound keys' unit and that a stated zero is re…
chappie-daemon Oct 25, 2026
7863d52
docs(rewrite): answer What 0 means with rejection, and re-anchor ADR …
chappie-daemon Oct 25, 2026
32b93dd
docs(rewrite): read the check path and the start path apart for a rej…
chappie-daemon Oct 25, 2026
923dbc8
fix(config): let only a number take the zero-bound rule, so a wrong t…
chappie-daemon Oct 25, 2026
46f99f7
docs(config): say a non-number reports once, and read the changeset's…
chappie-daemon Oct 25, 2026
8b3894f
docs(rewrite): quote the base's own bullet as superseded, and restore…
chappie-daemon Oct 25, 2026
d0d8a3f
docs(rewrite): cite reason 1 by full path so the meaning audit resolv…
chappie-daemon Oct 25, 2026
3c72dec
Merge rewrite/main into factory/018-zero-is-invalid
chappie-daemon Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/relay-zero-bound-invalid.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
---

fix(config): refuse a zero frame-memory budget or per-IP cap in the validator

#989 gave `network.frameMemoryBudgetBytes` and `limits.connection.maxConnectionsPerIp` a
meaning for `0` — *use the compiled default* — and ADR 0008 at this branch's base states it as
policy: "- **Any validation of the two numbers.** A zero budget or a zero cap is still not
refused — it is" (`docs/rewrite/decisions/0008-frame-memory-budget.md:369`). This change is the
one that supersedes those lines. It is also a fourth meaning for the same numeral in one
repository, where `limits.client.subscription` reads a zero as *disabled* and
`limits.event.pow.targetEventsPerSecond` reads it as *invalid*.

A stated `0` is now an issue, in the shape the validator already uses for the adaptive-pow
numbers: `network.frameMemoryBudgetBytes: frameMemoryBudgetBytes must be greater than 0` and
`limits.connection.maxConnectionsPerIp: maxConnectionsPerIp must be greater than 0` —
" \"maxConnectionsPerIp must be greater than 0\""
(`crates/nostream-config/src/validate.rs:95`). Both verdict surfaces report it and exit 2 —
`nostreamd --check` through " if check {" (`crates/nostreamd/src/settings_gate.rs:48`) — while
a start reports it and keeps serving, saying so with
" \"nostreamd: continuing on the shipped defaults ({} issue(s) in the user settings)\""
(`crates/nostreamd/src/settings_gate.rs:54`). Either way the operator learns which key to fix,
where a fallback turned the typo into a relay that silently budgeted 256 MiB. A non-number is not
a zero: that document keeps the shape walk's single finding, as before.

`RelayConfig::from_settings` therefore takes a present leaf as written and keeps `in_range` as
the ceiling question alone, "pub fn in_range(value: u64) -> bool {"
(`crates/nostream-relay/src/relay.rs:114`), which now spells
" value <= MAX_SETTING_BOUND as u64" (`crates/nostream-relay/src/relay.rs:115`). The zero
variant of `BoundRefusal`, its refusal sentence and its start-up `warn!` arm are gone with it;
an absent leaf still means the compiled default and an over-ceiling value still falls back with
the same two sentences as before.

The TypeScript tree is untouched: no `src/` or `test/` file changes, so the published package
has no user-visible change.
4 changes: 2 additions & 2 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ The settings below are listed in alphabetical order by name. Please keep this ta
| limits.client.subscription.maxFilters | Maximum number of filters per subscription. Defaults to 10. Disabled when set to zero. |
| limits.client.subscription.maxFilterValues | Maximum number of values allowed in each filter, counting its array criteria (`ids`, `authors`, `kinds`, `#<tag>`) together. Defaults to 2500. Disabled when set to zero. |
| limits.client.subscription.maxSubscriptions | Maximum number of subscriptions per connected client. Defaults to 10. Disabled when set to zero. |
| limits.connection.maxConnectionsPerIp | Maximum number of live sockets one source address may hold at once. Defaults to 64. Zero, or a value above 1099511627776, means the default. |
| limits.connection.maxConnectionsPerIp | Maximum number of live sockets one source address may hold at once. Unit: sockets. Defaults to 64. Zero is invalid; a value above 1099511627776 keeps the default. |
| limits.event.content[].kinds | List of event kinds to apply limit. Use `[min, max]` for ranges. Optional. |
| limits.event.content[].maxLength | Maximum length of `content`. Defaults to 1 MB. Disabled when set to zero. |
| limits.event.createdAt.maxNegativeDelta | Maximum number of seconds an event's `created_at` can be in the past. Defaults to zero. Disabled when set to zero. |
Expand Down Expand Up @@ -200,7 +200,7 @@ The settings below are listed in alphabetical order by name. Please keep this ta
| mirroring.static[].limits.event | Event limit overrides for this mirror. See configurations under limits.event. |
| mirroring.static[].secret | Secret to pass to relays. Nostream relays only. Optional. |
| mirroring.static[].skipAdmissionCheck | Disable the admission fee check for events coming from this mirror. |
| network.frameMemoryBudgetBytes | The queued outbound payload bytes every socket of this relay shares. Unit: bytes. Defaults to 268435456 (256 MiB). Zero, or a value above 1099511627776 (1 TiB), means the default. |
| network.frameMemoryBudgetBytes | The queued outbound payload bytes every socket of this relay shares. Unit: bytes. Defaults to 268435456 (256 MiB). Zero is invalid; a value above 1099511627776 (1 TiB) keeps the default. |
| network.maxPayloadSize | Maximum number of bytes accepted per WebSocket frame |
| network.remoteIpHeader | HTTP header from proxy containing IP address from client. |
| nip05.domainBlacklist | List of domains blocked from NIP-05 verification. Authors with NIP-05 at these domains will be rejected. |
Expand Down
30 changes: 12 additions & 18 deletions crates/nostream-cli/tests/config_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,33 +68,27 @@ fn the_paths_resolver_honours_the_config_dir() {
}

#[test]
fn config_validate_says_which_bound_setting_the_relay_will_not_use() {
// The CLI door and `nostreamd --check` are the two read-only ways onto the same validator;
// a bound the relay replaces must not be silent on one of them. Zero and an over-ceiling
// value are different facts and get different sentences.
fn config_validate_refuses_a_zero_bound() {
// The CLI door and `nostreamd --check` are the two read-only ways onto the same validator; a
// zero is an issue on both, and the document that carries one is not blessed (#996, ADR 0008).
let directory = tempfile::tempdir().expect("tempdir");
let path = directory.path().join("settings.yaml");
fs::write(
&path,
"network:\n frameMemoryBudgetBytes: 0\nlimits:\n connection:\n maxConnectionsPerIp: 1099511627777\n",
"network:\n frameMemoryBudgetBytes: 0\nlimits:\n connection:\n maxConnectionsPerIp: 0\n",
)
.expect("write");

let output =
config::validate(&workspace_root(), Some(directory.path()), Some(&path)).expect("validate");
assert_eq!(output.exit_code, 0);
let verdict = output.lines.join("\n");
assert!(
verdict.contains(
"network.frameMemoryBudgetBytes: 0 means the default, and the relay runs with it"
),
"verdict: {verdict}"
);
assert!(
verdict.contains(
"limits.connection.maxConnectionsPerIp: above the 1099511627776 a bound accepts; the key's value is ignored"
),
"verdict: {verdict}"
assert_eq!(output.exit_code, 2);
assert_eq!(
output.lines,
[
"network.frameMemoryBudgetBytes: frameMemoryBudgetBytes must be greater than 0",
"limits.connection.maxConnectionsPerIp: maxConnectionsPerIp must be greater than 0",
"settings invalid: 2 issues",
]
);
}

Expand Down
4 changes: 2 additions & 2 deletions crates/nostream-config/src/guided.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ fn build_categories() -> Vec<GuidedSettingCategory> {
SafeNonNegativeInteger,
),
validated(
"Frame memory budget (bytes, 0 means the default)",
"Frame memory budget (bytes)",
"network.frameMemoryBudgetBytes",
Number,
SafeNonNegativeInteger,
Expand Down Expand Up @@ -270,7 +270,7 @@ fn build_categories() -> Vec<GuidedSettingCategory> {
SafeNonNegativeInteger,
),
validated(
"Max connections per IP (sockets, 0 means the default)",
"Max connections per IP (sockets)",
"limits.connection.maxConnectionsPerIp",
Number,
SafeNonNegativeInteger,
Expand Down
26 changes: 26 additions & 0 deletions crates/nostream-config/src/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,32 @@ pub fn validate_document(candidate: &Value, defaults: &Value) -> Vec<Issue> {
));
}

// The two bounds the relay reads at run time are numerical settings before they are anything
// else, so a zero written for one is refused here, by name, exactly as the adaptive-pow
// numbers below are: absent still means the compiled default, and a stated zero is a literal
// the operator wrote (ADR 0008). Only a JSON number takes this rule: `to_number` reads a null,
// a boolean and a numeric string as numbers too, and a wrong type is the shape walk's one
// finding to make — two issues for one key would leave the operator to guess which to fix.
if let Some(budget @ Value::Number(_)) =
key_path(candidate, &["network", "frameMemoryBudgetBytes"])
&& !to_number_is_positive(Some(budget))
{
issues.push(Issue::new(
"network.frameMemoryBudgetBytes",
"frameMemoryBudgetBytes must be greater than 0",
));
}

if let Some(cap @ Value::Number(_)) =
key_path(candidate, &["limits", "connection", "maxConnectionsPerIp"])
&& !to_number_is_positive(Some(cap))
{
issues.push(Issue::new(
"limits.connection.maxConnectionsPerIp",
"maxConnectionsPerIp must be greater than 0",
));
}

if let Some(pow) = key_path(candidate, &["limits", "event", "pow"])
&& truthy_opt(key(pow, "enabled"))
{
Expand Down
84 changes: 84 additions & 0 deletions crates/nostream-config/tests/validator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,90 @@ fn a_non_array_rate_limits_value_is_left_to_the_shape_validator() {
);
}

// --- the two runtime bounds -------------------------------------------------------------

#[test]
fn rejects_a_zero_frame_memory_budget() {
// A zero here is the operator's literal, and the relay must not read it as the default
// (#996, ADR 0008). Absent still means the compiled default, which is why the document is
// the shipped one with this leaf changed and no other issue can appear.
let document = set_by_path(&defaults(), "network.frameMemoryBudgetBytes", number(0))
.expect("frameMemoryBudgetBytes is writable");
assert_eq!(
issues(&document),
vec![
"network.frameMemoryBudgetBytes: frameMemoryBudgetBytes must be greater than 0"
.to_owned()
]
);
}

#[test]
fn rejects_a_zero_max_connections_per_ip() {
let document = set_by_path(
&defaults(),
"limits.connection.maxConnectionsPerIp",
number(0),
)
.expect("maxConnectionsPerIp is writable");
assert_eq!(
issues(&document),
vec![
"limits.connection.maxConnectionsPerIp: maxConnectionsPerIp must be greater than 0"
.to_owned()
]
);
}

/// The shape walk's one finding for a wrong type, which is all such a document gets.
fn shape_only(path: &str, got: &str) -> Vec<String> {
vec![format!("{path}: Expected number, got {got}")]
}

#[test]
fn a_non_number_frame_memory_budget_is_only_a_shape_fault() {
// `to_number` reads a null, a boolean and a numeric string as numbers, so a rule written on
// the number alone reports twice for one key: two things for the operator to act on, and no
// way to tell which one is the mistake. The zero rule is the shape walk's neighbour, not its
// replacement (#996).
for (value, got) in [
(Value::String("0".to_owned()), "string"),
(Value::Null, "null"),
(Value::String("abc".to_owned()), "string"),
(Value::Bool(true), "boolean"),
] {
let document = set_by_path(&defaults(), "network.frameMemoryBudgetBytes", value.clone())
.expect("frameMemoryBudgetBytes is writable");
assert_eq!(
issues(&document),
shape_only("network.frameMemoryBudgetBytes", got),
"for {value:?}"
);
}
}

#[test]
fn a_non_number_max_connections_per_ip_is_only_a_shape_fault() {
for (value, got) in [
(Value::String("0".to_owned()), "string"),
(Value::Null, "null"),
(Value::String("abc".to_owned()), "string"),
(Value::Bool(false), "boolean"),
] {
let document = set_by_path(
&defaults(),
"limits.connection.maxConnectionsPerIp",
value.clone(),
)
.expect("maxConnectionsPerIp is writable");
assert_eq!(
issues(&document),
shape_only("limits.connection.maxConnectionsPerIp", got),
"for {value:?}"
);
}
}

// --- adaptive pow -----------------------------------------------------------------------

fn pow_with(overrides: &[(&str, Value)]) -> Value {
Expand Down
43 changes: 17 additions & 26 deletions crates/nostream-relay/src/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,25 +103,22 @@ impl Default for RelayConfig {
/// of queued frames for `network.frameMemoryBudgetBytes`, and 1.09 million sockets for
/// `limits.connection.maxConnectionsPerIp`. One number because one check reads both; it is far
/// above any deployment each way, and far below `usize::MAX`, so a stray digit cannot reach
/// [`FrameBudget::admit`]'s arithmetic. A value above it is treated like a zero: the default
/// stands (ADR 0008).
/// [`FrameBudget::admit`]'s arithmetic. A value above it keeps the compiled default (ADR 0008).
pub const MAX_SETTING_BOUND: usize = 1024 * 1024 * 1024 * 1024;

/// Whether an operator's value for a bound is one the relay can run with: positive and at most
/// Whether an operator's value for a bound is one the relay can run with: at most
/// [`MAX_SETTING_BOUND`]. Public because the daemon's settings gate reports the same verdict
/// before a relay exists, and one predicate is what keeps `--check` and the relay from
/// disagreeing (ADR 0008).
/// disagreeing (ADR 0008). Zero is in range: the validator refuses it, so a value that reaches
/// here is one the relay runs with as written.
pub fn in_range(value: u64) -> bool {
value > 0 && value <= MAX_SETTING_BOUND as u64
value <= MAX_SETTING_BOUND as u64
}

/// Why a value was not taken from a bound's setting: the question [`in_range`] answers, said out
/// loud, so the report can distinguish the two cases the predicate collapses — a zero, which the
/// document says means the default, from a value above the ceiling, which it does not.
/// loud, so the report can name the ceiling the value crossed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BoundRefusal {
/// The operator wrote `0`, which the shipped document documents as "the default".
Zero,
/// The value is above [`MAX_SETTING_BOUND`].
AboveCeiling(u64),
}
Expand Down Expand Up @@ -149,40 +146,32 @@ pub fn refused_bounds(settings: &Settings) -> Vec<(&'static str, BoundRefusal)>
refused
}

/// The two ways a bound's setting is not taken, or `None` when it is.
/// Whether a bound's setting is not taken, or `None` when it is.
///
/// It asks [`in_range`] first, so the constructor and every report agree by construction rather
/// than by two comparisons that happen to match: a value `in_range` accepts is never refused here.
pub fn bound_refusal(value: u64) -> Option<BoundRefusal> {
if in_range(value) {
None
} else if value == 0 {
Some(BoundRefusal::Zero)
} else {
Some(BoundRefusal::AboveCeiling(MAX_SETTING_BOUND as u64))
}
}

/// The sentence one refused bound is reported with, for both doors: a zero (documented as the
/// default) and a value above the ceiling are different facts, and the words live beside the
/// The sentence one refused bound is reported with, for both doors; the words live beside the
/// decision so the daemon's gate and the CLI cannot phrase it differently.
pub fn refusal_message(setting: &str, reason: BoundRefusal) -> String {
match reason {
BoundRefusal::Zero => format!("{setting}: 0 means the default, and the relay runs with it"),
BoundRefusal::AboveCeiling(ceiling) => {
format!("{setting}: above the {ceiling} a bound accepts; the key's value is ignored")
}
}
}

/// Reports one bound's setting that the relay will not run with, as one structured line saying
/// which of the two reasons it was: a zero (documented as the default) and a value above the
/// ceiling are different facts.
/// the ceiling it crossed.
fn warn_refused_bound(setting: &'static str, value: u64, default: usize) {
match bound_refusal(value) {
Some(BoundRefusal::Zero) => {
tracing::warn!(setting, default, "a bound set to 0 runs with its default")
}
Some(BoundRefusal::AboveCeiling(ceiling)) => tracing::warn!(
setting,
value,
Expand All @@ -198,17 +187,19 @@ impl RelayConfig {
/// The configuration a relay built from `settings` runs with: [`RelayConfig::default`] for
/// every knob the capture fixes in code, with the two bounds an operator sets read from
/// their leaves — `network.frameMemoryBudgetBytes` and
/// `limits.connection.maxConnectionsPerIp` — when present and non-zero.
/// `limits.connection.maxConnectionsPerIp` — when present.
///
/// This is the one place either setting is interpreted, so `nostreamd` and the tests cannot
/// disagree about it. `drain_timeout` stays the composition root's to resolve: it is an
/// environment read (ADR 0004), not a setting.
///
/// A leaf is taken when it is present, non-zero and at most [`MAX_SETTING_BOUND`]; anything
/// else keeps the default. The ceiling is not cosmetic: `usize::MAX` fits a `usize`, so it
/// would pass a bare `> 0` check and reach [`FrameBudget::admit`], whose fit guard uses
/// `saturating_sub` while its accounting adds unchecked — a budget that cannot be
/// exhausted, and an add that overflows.
/// A leaf is taken as written whenever it is at most [`MAX_SETTING_BOUND`]; an absent leaf
/// keeps the default, and so does a value above the ceiling. The ceiling is not cosmetic:
/// `usize::MAX` fits a `usize`, so it would pass a bare range check and reach
/// [`FrameBudget::admit`], whose fit guard uses `saturating_sub` while its accounting adds
/// unchecked — a budget that cannot be exhausted, and an add that overflows. A zero never
/// reaches this constructor from a loaded document: the validator refuses it by name
/// (ADR 0008).
pub fn from_settings(settings: &Settings) -> Self {
let mut config = Self::default();

Expand Down
Loading
Loading