Skip to main content

decdn_cli/commands/
config.rs

1//! `decdn config init` and `decdn config validate` — manage the deCDN
2//! node TOML config file from the operator side.
3
4use anyhow::Context;
5use decdn_common::{cli, config};
6
7use crate::known_chains;
8
9/// Validate a configuration and print a resolved summary.
10///
11/// Runs the daemon's resolver against the same TOML + CLI + env-var
12/// inputs `decdn-node run` would use, then prints the result. Does not
13/// bind any ports, does not connect to the JSON-RPC endpoint, and does
14/// not open the cache. Does touch the filesystem to verify the config
15/// itself: reads the TOML, and stats / opens the configured
16/// `blockchain.eth_keystore` for readability (the resolver rejects
17/// missing paths, directories, and broken symlinks at this point so
18/// the daemon doesn't fail later in startup).
19pub fn config_validate(
20    config_path: Option<&std::path::Path>,
21    args: &cli::ConfigValidateArgs,
22) -> anyhow::Result<()> {
23    let (resolved, notices) = config::resolve_config(config_path, &args.run)?;
24    let source = effective_source(config_path, cli::common::default_config_path)?;
25    let mut stdout = std::io::stdout().lock();
26    write_validate_summary(&mut stdout, source.as_deref(), &resolved, &notices)
27        .map_err(|e| anyhow::anyhow!("failed to write summary: {e}"))
28}
29
30/// Resolve the config source actually consulted. When `--config` is omitted,
31/// the default path is auto-loaded only if present, so report the file that
32/// `resolve_config` will actually read — not just the explicit flag.
33///
34/// Uses `try_exists` rather than `exists`: a permission error on the default
35/// path must be surfaced, not silently reported as "no config file" — the
36/// whole point of `validate` is telling the operator what the node sees.
37pub fn effective_source(
38    config_path: Option<&std::path::Path>,
39    default: impl FnOnce() -> Option<std::path::PathBuf>,
40) -> anyhow::Result<Option<std::path::PathBuf>> {
41    if let Some(p) = config_path {
42        return Ok(Some(p.to_path_buf()));
43    }
44    let Some(default) = default() else {
45        return Ok(None);
46    };
47    match default.try_exists() {
48        Ok(true) => Ok(Some(default)),
49        Ok(false) => Ok(None),
50        Err(e) => Err(anyhow::anyhow!(
51            "cannot determine whether default config {} exists: {e}",
52            default.display()
53        )),
54    }
55}
56
57/// Render the validation summary. Separated from [`config_validate`] so tests
58/// can capture the output into a buffer and assert on the printed contract —
59/// in particular that `rpc_url` and `otlp_endpoint` values never appear.
60// Linear "print each resolved config field" flow sitting right at the 100-line
61// boundary; splitting the writeln! sequence across helpers would obscure the
62// field-by-field narrative more than the length does.
63#[allow(clippy::too_many_lines)]
64pub fn write_validate_summary<W: std::io::Write>(
65    w: &mut W,
66    source: Option<&std::path::Path>,
67    resolved: &config::ResolvedConfig,
68    notices: &[config::ConfigNotice],
69) -> std::io::Result<()> {
70    writeln!(w, "config valid")?;
71    match source {
72        Some(path) => writeln!(w, "  source:                   {}", path.display())?,
73        None => writeln!(
74            w,
75            "  source:                   (defaults + env only, no config file)"
76        )?,
77    }
78    writeln!(
79        w,
80        "  data_dir:                 {}",
81        resolved.identity.data_dir.display()
82    )?;
83    if let Some(region) = &resolved.identity.region {
84        writeln!(w, "  region:                   {region}")?;
85    }
86    writeln!(
87        w,
88        "  bind_port:                {}",
89        resolved.network.bind_port
90    )?;
91    // One line per configured relay; nothing when the list is empty (the node
92    // then falls back to the n0 default relays). Relay URLs can carry
93    // `user:pass@` userinfo, so redact it (host stays visible) — the same
94    // treatment pkarr_url gets below, and the reason rpc_url/otlp_endpoint are
95    // fully hidden in this same summary (#862).
96    for relay in &resolved.network.relay_urls {
97        writeln!(
98            w,
99            "  relay_url:                {}",
100            decdn_common::redact::redact_userinfo(relay)
101        )?;
102    }
103    // Operator-configurable discovery (#818); nothing printed when unset (the
104    // node then uses the n0 pkarr/DNS default). A pkarr_url is an infra relay
105    // endpoint — pkarr authenticates by the Ed25519-signed packet, not a URL
106    // token — so its credentials, if any, are normally `user:pass@` userinfo.
107    // Redact that (host stays visible for diagnostics) rather than fully hiding
108    // it like rpc_url/otlp_endpoint, whose secret commonly lives in the path or
109    // query. Caveat: `redact_userinfo` does NOT scrub a path/query secret, so a
110    // nonstandard relay that put one there would still print it — acceptable
111    // given pkarr's auth model and that relays themselves redact userinfo
112    // above. dns_origin is a plain domain. Peer entries (id/relay/addrs) are
113    // not echoed (verbose) — only the count.
114    let discovery = &resolved.network.discovery;
115    if let Some(pkarr) = &discovery.pkarr_url {
116        writeln!(
117            w,
118            "  discovery.pkarr_url:      {}",
119            decdn_common::redact::redact_userinfo(pkarr)
120        )?;
121    }
122    if let Some(origin) = &discovery.dns_origin {
123        writeln!(w, "  discovery.dns_origin:     {origin}")?;
124    }
125    if !discovery.peers.is_empty() {
126        writeln!(w, "  discovery.peers:          {}", discovery.peers.len())?;
127    }
128    writeln!(
129        w,
130        "  rpc_url:                  <redacted> ({} chars)",
131        resolved.blockchain.rpc_url.len()
132    )?;
133    writeln!(
134        w,
135        "  eth_keystore:             {}",
136        resolved.blockchain.eth_keystore.display()
137    )?;
138    writeln!(
139        w,
140        "  payment_pool_address:  {}",
141        resolved.blockchain.payment_pool_address
142    )?;
143    writeln!(
144        w,
145        "  capacity_bond_address: {}",
146        resolved.blockchain.capacity_bond_address
147    )?;
148    writeln!(
149        w,
150        "  origin_assignment_address: {}",
151        resolved
152            .blockchain
153            .origin_assignment_address
154            .as_deref()
155            .unwrap_or("(unset — origin directory empty: pull-through gate and FIND_VALUE fallback find no origins)")
156    )?;
157    writeln!(
158        w,
159        "  publisher_registry_address: {}",
160        resolved
161            .blockchain
162            .publisher_registry_address
163            .as_deref()
164            .unwrap_or("(unset — origin directory empty: pull-through gate and FIND_VALUE fallback find no origins)")
165    )?;
166    writeln!(
167        w,
168        "  content_blacklist_address: {}",
169        resolved
170            .blockchain
171            .content_blacklist_address
172            .as_deref()
173            .unwrap_or("(unset — REQUIRED: the node refuses to start without it)")
174    )?;
175    writeln!(
176        w,
177        "  content_blacklist_poll_interval_sec: {}",
178        resolved.blockchain.content_blacklist_poll_interval_sec
179    )?;
180    writeln!(
181        w,
182        "  chain_staleness_grace_sec: {}",
183        resolved.blockchain.chain_staleness_grace_sec
184    )?;
185    writeln!(
186        w,
187        "  rpc_watchdog_interval_sec: {}",
188        resolved.blockchain.rpc_watchdog_interval_sec
189    )?;
190    writeln!(
191        w,
192        "  event_poll_interval_ms: {}",
193        resolved.blockchain.event_poll_interval_ms
194    )?;
195    writeln!(
196        w,
197        "  get_logs_max_block_span: {}",
198        resolved.blockchain.get_logs_max_block_span
199    )?;
200    writeln!(
201        w,
202        "  fee_shares_poll_interval_sec: {}",
203        resolved.blockchain.fee_shares_poll_interval_sec
204    )?;
205    writeln!(
206        w,
207        "  redeem_threshold_micro_usdc: {}",
208        resolved.blockchain.redeem_threshold_micro_usdc
209    )?;
210    writeln!(
211        w,
212        "  redeem_interval_secs: {}",
213        resolved.blockchain.redeem_interval_secs
214    )?;
215    writeln!(
216        w,
217        "  buyer_working_deposit_micro_usdc: {}",
218        resolved.blockchain.buyer_working_deposit_micro_usdc
219    )?;
220    writeln!(
221        w,
222        "  buyer_max_approve: {}",
223        resolved.blockchain.buyer_max_approve
224    )?;
225    writeln!(
226        w,
227        "  pool_min_remaining_deposit_micro_usdc: {}",
228        resolved.blockchain.pool_min_remaining_deposit_micro_usdc
229    )?;
230    writeln!(
231        w,
232        "  pool_floor_signer_live_windows: {}",
233        resolved.blockchain.pool_floor_signer_live_windows
234    )?;
235    writeln!(
236        w,
237        "  cache_dir:                {}",
238        resolved.cache.cache_dir.display()
239    )?;
240    writeln!(
241        w,
242        "  cache_size_mb:            {}",
243        resolved.cache.cache_size_mb
244    )?;
245    writeln!(
246        w,
247        "  max_blob_size_mb:         {}",
248        resolved.cache.max_blob_size_mb
249    )?;
250    match resolved.cache.max_rate_per_mb {
251        0 => writeln!(w, "  max_rate_per_mb:          unlimited (buyer)")?,
252        n => writeln!(w, "  max_rate_per_mb:          {n} (buyer ceiling)")?,
253    }
254    match resolved.cache.gc_interval_sec {
255        0 => writeln!(w, "  gc_interval_sec:          disabled")?,
256        n => writeln!(w, "  gc_interval_sec:          {n}")?,
257    }
258    match resolved.cache.fs_rescan_interval_sec {
259        0 => writeln!(
260            w,
261            "  fs_rescan_interval_sec:   disabled (startup + reload only)"
262        )?,
263        n => writeln!(w, "  fs_rescan_interval_sec:   {n}")?,
264    }
265    writeln!(
266        w,
267        "  eviction:                 high_water_pct={}, target_pct={}, per_sweep_budget={}, tick_secs={}",
268        resolved.cache.eviction_high_water_pct,
269        resolved.cache.eviction_target_pct,
270        resolved.cache.eviction_per_sweep_budget,
271        resolved.cache.eviction_tick_secs
272    )?;
273    if resolved.cache.node_to_node_pull_through_enabled {
274        writeln!(
275            w,
276            "  node_to_node_pull:        enabled (probe_fanout={}, pull_timeout_sec={}, stall_window_sec={}, min_throughput_bps={})",
277            resolved.cache.node_pull_probe_fanout,
278            resolved.cache.node_pull_timeout_sec,
279            resolved.cache.node_pull_stall_window_sec,
280            resolved.cache.node_pull_min_throughput_bps
281        )?;
282    } else {
283        writeln!(w, "  node_to_node_pull:        disabled")?;
284    }
285    if resolved.cache.relay_foreign_namespaces {
286        writeln!(w, "  relay_foreign_namespaces: true")?;
287    } else {
288        writeln!(w, "  relay_foreign_namespaces: false (origin-only node)")?;
289    }
290    writeln!(
291        w,
292        "  rate_per_mb:              {}",
293        resolved.payment.rate_per_mb
294    )?;
295    writeln!(
296        w,
297        "  log_level:                {}",
298        resolved.observability.log_level
299    )?;
300    writeln!(
301        w,
302        "  metrics_port:             {}",
303        resolved.observability.metrics_port
304    )?;
305    writeln!(
306        w,
307        "  metrics_bind:             {}",
308        resolved.observability.metrics_bind
309    )?;
310    match resolved.observability.admin_port {
311        Some(p) => writeln!(w, "  admin_port:               {p}")?,
312        None => writeln!(w, "  admin_port:               disabled")?,
313    }
314    // otlp_endpoint URLs commonly carry bearer tokens or API keys in the
315    // path or query, so redact like rpc_url.
316    if let Some(otlp) = &resolved.observability.otlp_endpoint {
317        writeln!(
318            w,
319            "  otlp_endpoint:            <redacted> ({} chars)",
320            otlp.len()
321        )?;
322    }
323    // Surface the kill-switch / rate-limit knobs of the otherwise-silent
324    // sections — the throttles an operator most needs to confirm, not every
325    // field. All numeric/bool/bind-addr, so no secret-redaction concern.
326    writeln!(
327        w,
328        "  security.max_concurrent_handlers: {}",
329        resolved.security.max_concurrent_handlers
330    )?;
331    writeln!(
332        w,
333        "  security.per_source_rate_per_sec: {}",
334        resolved.security.per_source_rate_per_sec
335    )?;
336    writeln!(
337        w,
338        "  security.per_source_burst: {}",
339        resolved.security.per_source_burst
340    )?;
341    writeln!(
342        w,
343        "  dht.per_peer_rate_per_sec: {}",
344        resolved.dht.per_peer_rate_per_sec
345    )?;
346    writeln!(
347        w,
348        "  dht.global_rate_per_sec:  {}",
349        resolved.dht.global_rate_per_sec
350    )?;
351    writeln!(
352        w,
353        "  probe.per_peer_rate_per_sec: {}",
354        resolved.probe.per_peer_rate_per_sec
355    )?;
356    writeln!(
357        w,
358        "  probe.global_rate_per_sec: {}",
359        resolved.probe.global_rate_per_sec
360    )?;
361    // Download-receipt audit log (#802). The log lives at a fixed filename
362    // inside data_dir; surface the resolved path so an operator can confirm
363    // where receipts land without reading the daemon source, alongside the
364    // rotation cap and retained-backup count that bound its disk use. The path
365    // is derived (not a configurable field) — the daemon writes it to this
366    // canonical name under data_dir.
367    writeln!(
368        w,
369        "  receipts.log_path:        {}",
370        resolved
371            .identity
372            .data_dir
373            .join(config::RECEIPT_LOG_FILE)
374            .display()
375    )?;
376    writeln!(
377        w,
378        "  receipts.max_file_bytes:  {}",
379        resolved.receipts.max_file_bytes
380    )?;
381    writeln!(
382        w,
383        "  receipts.retained_files:  {}",
384        resolved.receipts.retained_files
385    )?;
386    // Counts, not contents. An operator running `config validate` after adding a
387    // takedown wants confirmation the entries were accepted — and a zero here is
388    // the tell that a `[content]` section landed in the wrong file. Printing the
389    // hashes themselves would put the subject of a legal order into terminal
390    // scrollback and any CI log that captures it.
391    writeln!(
392        w,
393        "  content.denied_hashes:    {}",
394        resolved.content.denied_hashes.len()
395    )?;
396    writeln!(
397        w,
398        "  content.denied_origins:   {}",
399        resolved.content.denied_origins.len()
400    )?;
401    write_notices(w, notices)
402}
403
404/// Render the resolve-time notices under the field summary, one line each.
405///
406/// The config still resolved — a notice never fails validation — so these sit
407/// below the summary rather than replacing it, and the leading `config valid`
408/// line stands. They go to the same writer as the summary, not to stderr,
409/// because `config validate` exists to tell an operator what the node sees and
410/// a notice is part of that answer.
411fn write_notices<W: std::io::Write>(
412    w: &mut W,
413    notices: &[config::ConfigNotice],
414) -> std::io::Result<()> {
415    if notices.is_empty() {
416        return Ok(());
417    }
418    writeln!(w, "notices ({}):", notices.len())?;
419    for notice in notices {
420        let level = match notice.level {
421            config::ConfigNoticeLevel::Warn => "warning",
422            config::ConfigNoticeLevel::Info => "info",
423        };
424        writeln!(w, "  - {level}: {}: {}", notice.field, notice.message)?;
425    }
426    Ok(())
427}
428
429/// The config shape `config init` writes, implied by the role flags (#1772).
430///
431/// Role is implied by `--origin`, not a separate flag: presence of an origin
432/// backend IS the role signal, exactly mirroring the runtime's role-derived
433/// `cache.relay_foreign_namespaces` default — so the generated config and the
434/// resolver agree by construction. `--client` is a distinct role, not the
435/// absence of the others: a consumer that fetches and pays, with none of the
436/// serving sections.
437#[derive(Debug)]
438pub enum Role {
439    /// No role flag: a serving node with no origin backend — relays foreign
440    /// namespaces for pay (`relay_foreign_namespaces` derives to `true`).
441    Relay,
442    /// `--origin <URL>`: a serving node with an active `[cache.origin]`
443    /// backend (`relay_foreign_namespaces` derives to `false`, origin-only).
444    Origin(OriginSpec),
445    /// `--client`: a fetch-only consumer — identity + chain coordinates,
446    /// none of the cache/origin/serving sections.
447    Client,
448}
449
450/// The `[cache.origin]` backend `--origin` asked for, one variant per
451/// `OriginConfig` kind. Each spelling of the flag value maps to exactly one:
452/// `http(s)://` → [`OriginSpec::Http`], `file:///` → [`OriginSpec::Fs`],
453/// `s3` / `s3://<bucket>` → [`OriginSpec::S3`].
454#[derive(Debug)]
455pub enum OriginSpec {
456    /// HTTP(S) pull-through base URL.
457    Http(decdn_config_types::OriginUrl),
458    /// Local filesystem root (from a `file:///` URL).
459    Fs(std::path::PathBuf),
460    /// S3-compatible origin. `bucket` is `None` for the bare `s3` spelling —
461    /// the emitted block then carries a placeholder bucket to edit.
462    S3 {
463        /// The bucket named in `s3://<bucket>`, or `None` for the bare `s3`
464        /// spelling.
465        bucket: Option<String>,
466    },
467}
468
469/// Map the `config init` role flags to a [`Role`]. `--origin` and `--client`
470/// are mutually exclusive at the clap layer (`conflicts_with`).
471fn resolve_role(args: &cli::ConfigInitArgs) -> anyhow::Result<Role> {
472    if args.client {
473        return Ok(Role::Client);
474    }
475    match args.origin.as_deref() {
476        Some(raw) => Ok(Role::Origin(parse_origin_spec(raw)?)),
477        None => Ok(Role::Relay),
478    }
479}
480
481/// Parse the `--origin` flag value into an [`OriginSpec`].
482///
483/// Every accepted value is validated through the same guard the daemon's
484/// config resolver applies to that origin kind — `parse_origin_url` for http,
485/// `validate_s3_bucket_name` for a named bucket — so a value `config init`
486/// accepts cannot fail resolution. The S3 key `prefix` stays a commented key
487/// (its validation and normalization live in the resolver alone), so an
488/// `s3://bucket/prefix` spelling is rejected rather than half-supported.
489fn parse_origin_spec(raw: &str) -> anyhow::Result<OriginSpec> {
490    if raw == "s3" {
491        return Ok(OriginSpec::S3 { bucket: None });
492    }
493    // The raw value is not echoed on a parse failure: an unparseable origin
494    // string can still carry `user:pass@` userinfo, and the redaction helpers
495    // only work on values that parsed.
496    let url = url::Url::parse(raw).context(
497        "invalid --origin value (expected an http(s):// or file:/// URL, `s3`, or s3://<bucket>)",
498    )?;
499    match url.scheme() {
500        "http" | "https" => Ok(OriginSpec::Http(decdn_config_types::parse_origin_url(raw)?)),
501        "file" => {
502            // `to_file_path` rejects a remote host (`file://host/path`) and
503            // percent-decodes the path; the result is absolute by construction.
504            let path = url.to_file_path().map_err(|()| {
505                anyhow::anyhow!(
506                    "--origin file:// URL must name a local absolute path \
507                     (file:///var/lib/decdn/origin)"
508                )
509            })?;
510            Ok(OriginSpec::Fs(path))
511        }
512        "s3" => {
513            let bucket = url
514                .host_str()
515                .ok_or_else(|| anyhow::anyhow!("--origin s3:// URL must name a bucket"))?
516                .to_string();
517            decdn_common::config::validate_s3_bucket_name(&bucket)
518                .with_context(|| format!("invalid --origin s3 bucket `{bucket}`"))?;
519            anyhow::ensure!(
520                url.path().is_empty() || url.path() == "/",
521                "--origin s3://<bucket> takes no key prefix; set the commented `prefix` key \
522                 in the generated [cache.origin] section instead"
523            );
524            Ok(OriginSpec::S3 {
525                bucket: Some(bucket),
526            })
527        }
528        other => anyhow::bail!(
529            "unsupported --origin scheme {other:?}: pass an http(s):// or file:/// URL, `s3`, \
530             or s3://<bucket>"
531        ),
532    }
533}
534
535/// Write a default TOML configuration file.
536pub fn config_init(args: &cli::ConfigInitArgs) -> anyhow::Result<()> {
537    let output = args
538        .output
539        .as_deref()
540        .map(cli::common::expand_tilde)
541        .or_else(cli::default_config_path)
542        .ok_or_else(|| anyhow::anyhow!("cannot determine config path: home dir not found"))?;
543
544    if output.exists() && !args.force {
545        anyhow::bail!(
546            "config file already exists at {}; use --force to overwrite",
547            output.display()
548        );
549    }
550
551    if let Some(parent) = output.parent() {
552        std::fs::create_dir_all(parent)
553            .map_err(|e| anyhow::anyhow!("failed to create directory {}: {e}", parent.display()))?;
554    }
555
556    let role = resolve_role(args)?;
557    let chain = known_chains::resolve(args.chain.as_deref())?;
558    let contents = render_config(chain, &role)?;
559
560    std::fs::write(&output, &contents)
561        .map_err(|e| anyhow::anyhow!("failed to write config file {}: {e}", output.display()))?;
562
563    let role_word = match &role {
564        Role::Relay => "relay-node",
565        Role::Origin(_) => "origin-node",
566        Role::Client => "client",
567    };
568    // A client's keystore lives in the client-scoped data dir, not `key-gen`'s
569    // node-shaped default.
570    let key_gen = match &role {
571        Role::Client => "decdn key-gen --output-dir ~/.decdn/client",
572        Role::Relay | Role::Origin(_) => "decdn key-gen",
573    };
574    match chain {
575        Some(c) => println!(
576            "wrote {} {role_word} config to {} (run `{key_gen}` to create the keystore)",
577            c.label,
578            output.display()
579        ),
580        None => println!(
581            "wrote blank {role_word} config template to {}",
582            output.display()
583        ),
584    }
585    if let Role::Origin(spec) = &role {
586        let backend = match spec {
587            OriginSpec::Http(url) => decdn_config_types::redact_for_log(url.as_url()),
588            OriginSpec::Fs(path) => format!("fs {}", path.display()),
589            OriginSpec::S3 { bucket: Some(b) } => format!("s3 bucket {b}"),
590            OriginSpec::S3 { bucket: None } => {
591                "s3 (edit the MUST-EDIT bucket in [cache.origin])".to_string()
592            }
593        };
594        println!(
595            "origin backend: {backend} — cache.relay_foreign_namespaces derives to false \
596             (origin-only)"
597        );
598    }
599    Ok(())
600}
601
602/// Render the config `config init` writes for the selected chain and role.
603///
604/// Chain `None` starts from the blank template; `Some(chain)` bakes that
605/// chain's id, RPC, and manifest contract addresses into the `[blockchain]`
606/// section — so the file runs out of the box once a keystore exists. The role
607/// then picks the shape: [`Role::Client`] uses the trimmed [`CLIENT_CONFIG`]
608/// template, [`Role::Origin`] additionally activates a `[cache.origin]` block,
609/// and [`Role::Relay`] leaves the node template as-is.
610fn render_config(chain: Option<&known_chains::KnownChain>, role: &Role) -> anyhow::Result<String> {
611    if let Role::Client = role {
612        let Some(chain) = chain else {
613            return Ok(CLIENT_CONFIG.to_string());
614        };
615        let filled = render_client_blockchain_section(chain)?;
616        // The client template ends with `[blockchain]`, so the splice runs to EOF.
617        return splice_section(CLIENT_CONFIG, &filled, "\n[blockchain]\n", None);
618    }
619    let base = match chain {
620        None => DEFAULT_CONFIG.to_string(),
621        Some(chain) => {
622            let filled = render_blockchain_section(chain)?;
623            splice_section(
624                DEFAULT_CONFIG,
625                &filled,
626                "\n[blockchain]\n",
627                Some("\n[cache]\n"),
628            )?
629        }
630    };
631    match role {
632        Role::Origin(spec) => insert_origin_block(&base, spec),
633        Role::Relay | Role::Client => Ok(base),
634    }
635}
636
637/// Build the filled `[blockchain]` section for a known chain.
638fn render_blockchain_section(chain: &known_chains::KnownChain) -> anyhow::Result<String> {
639    let a = chain.addresses()?;
640    Ok(format!(
641        "[blockchain]\n\
642         # Baked in for {label} (chain {chain_id}) from the shipped deployment\n\
643         # manifest (contracts/deployments/{chain_id}.json). Ready to run as-is;\n\
644         # `eth_keystore` defaults to <data_dir>/keystore.json — create it with\n\
645         # `decdn key-gen`. Point `rpc_url` at your own provider for production.\n\
646         rpc_url = \"{rpc}\"\n\
647         chain_id = {chain_id}\n\
648         # eth_keystore = \"~/.decdn/keystore.json\"   # defaults to <data_dir>/keystore.json\n\
649         # --- Contract addresses (from deployments/{chain_id}.json) ---\n\
650         payment_pool_address       = \"{payment_pool}\"\n\
651         capacity_bond_address      = \"{capacity_bond}\"\n\
652         slash_judge_address        = \"{slash_judge}\"\n\
653         content_blacklist_address  = \"{content_blacklist}\"\n\
654         origin_assignment_address  = \"{origin_assignment}\"\n\
655         publisher_registry_address = \"{publisher_registry}\"\n\
656         slash_appeal_address       = \"{slash_appeal}\"\n\
657         usdc_address               = \"{usdc}\"\n\
658         # Optional tuning knobs (economics, poll intervals) are commented in the\n\
659         # blank template — see `decdn config init --chain none`.\n\
660         \n",
661        label = chain.label,
662        chain_id = chain.chain_id,
663        rpc = chain.public_rpc,
664        payment_pool = a.payment_pool,
665        capacity_bond = a.capacity_bond,
666        slash_judge = a.slash_judge,
667        content_blacklist = a.content_blacklist,
668        origin_assignment = a.origin_assignment,
669        publisher_registry = a.publisher_registry,
670        slash_appeal = a.slash_appeal,
671        usdc = a.usdc,
672    ))
673}
674
675/// Build the filled `[blockchain]` section for the client shape of a known
676/// chain. The client consumes `rpc_url`, `chain_id`, `eth_keystore`,
677/// `payment_pool_address`, and `usdc_address`; the remaining contract
678/// addresses are daemon/operator keys the client ignores, kept active so the
679/// same file also passes `decdn config validate` (whose resolver requires
680/// `capacity_bond_address` and `slash_judge_address`).
681fn render_client_blockchain_section(chain: &known_chains::KnownChain) -> anyhow::Result<String> {
682    let a = chain.addresses()?;
683    Ok(format!(
684        "[blockchain]\n\
685         # Baked in for {label} (chain {chain_id}) from the shipped deployment\n\
686         # manifest (contracts/deployments/{chain_id}.json). The client consumes the\n\
687         # first block of keys; the remaining contract addresses are daemon/operator\n\
688         # keys it ignores, kept active so this file also passes `decdn config\n\
689         # validate`. Ready to fetch as-is once the keystore exists (`decdn key-gen\n\
690         # --output-dir ~/.decdn/client`) and holds USDC. Point `rpc_url` at your own\n\
691         # provider for production.\n\
692         rpc_url = \"{rpc}\"\n\
693         chain_id = {chain_id}\n\
694         # eth_keystore = \"~/.decdn/client/keystore.json\"   # defaults to <data_dir>/keystore.json\n\
695         payment_pool_address       = \"{payment_pool}\"\n\
696         usdc_address               = \"{usdc}\"\n\
697         # buyer_working_deposit_micro_usdc = 10000000  # deposit when OPENING a pool and refill target; must be > 0; default 10 USDC\n\
698         # buyer_max_approve = false          # exact deposit-sized USDC approvals (client default); true opts into an unlimited standing approval\n\
699         # --- Daemon/operator addresses (ignored by the client) ---\n\
700         capacity_bond_address      = \"{capacity_bond}\"\n\
701         slash_judge_address        = \"{slash_judge}\"\n\
702         content_blacklist_address  = \"{content_blacklist}\"\n\
703         origin_assignment_address  = \"{origin_assignment}\"\n\
704         publisher_registry_address = \"{publisher_registry}\"\n\
705         slash_appeal_address       = \"{slash_appeal}\"\n",
706        label = chain.label,
707        chain_id = chain.chain_id,
708        rpc = chain.public_rpc,
709        payment_pool = a.payment_pool,
710        usdc = a.usdc,
711        capacity_bond = a.capacity_bond,
712        slash_judge = a.slash_judge,
713        content_blacklist = a.content_blacklist,
714        origin_assignment = a.origin_assignment,
715        publisher_registry = a.publisher_registry,
716        slash_appeal = a.slash_appeal,
717    ))
718}
719
720/// Replace one top-level section of a template with a filled block.
721///
722/// The section runs from `start_header` to `end_header` — or to the end of the
723/// template when `end_header` is `None` (for a template whose spliced section
724/// is last). The needed headers are guaranteed present by the template guard
725/// tests; a missing marker is a hard error rather than a silent mis-splice.
726fn splice_section(
727    template: &str,
728    filled: &str,
729    start_header: &str,
730    end_header: Option<&str>,
731) -> anyhow::Result<String> {
732    let start = template
733        .find(start_header)
734        .map(|i| i + 1)
735        .with_context(|| {
736            format!(
737                "template is missing the {} section header",
738                start_header.trim()
739            )
740        })?;
741    let rest = template
742        .get(start..)
743        .context("template section slice out of bounds")?;
744    let end = match end_header {
745        Some(header) => {
746            let rel = rest.find(header).map(|i| i + 1).with_context(|| {
747                format!(
748                    "template is missing the {} section header after {}",
749                    header.trim(),
750                    start_header.trim()
751                )
752            })?;
753            start + rel
754        }
755        None => template.len(),
756    };
757    let head = template
758        .get(..start)
759        .context("template head slice out of bounds")?;
760    let tail = template
761        .get(end..)
762        .context("template tail slice out of bounds")?;
763    Ok(format!("{head}{filled}{tail}"))
764}
765
766/// Splice the active `[cache.origin]` block `--origin <URL>` asked for into a
767/// rendered node config, at the end of its `[cache]` section — immediately
768/// before the `[payment]` header.
769///
770/// Everything between the `[cache]` header and `[payment]` in the template is
771/// comments, so the last active header above the insertion point is `[cache]`
772/// itself. That placement is why the commented `relay_foreign_namespaces`
773/// override line sits *above* the `[cache.origin]` header: uncommented in
774/// place it lands in `[cache]`, where the key belongs — below the header it
775/// would parse as an unknown `[cache.origin]` field and fail resolution.
776fn insert_origin_block(template: &str, spec: &OriginSpec) -> anyhow::Result<String> {
777    let at = template
778        .find("\n[payment]\n")
779        .map(|i| i + 1)
780        .context("rendered config is missing the [payment] section header")?;
781    let head = template
782        .get(..at)
783        .context("rendered config head slice out of bounds")?;
784    let tail = template
785        .get(at..)
786        .context("rendered config tail slice out of bounds")?;
787    let backend = match spec {
788        // `url` came through `parse_origin_url`, whose normalization
789        // percent-encodes characters that could break out of a TOML basic
790        // string.
791        OriginSpec::Http(url) => format!(
792            "[cache.origin]\n\
793             kind = \"http\"\n\
794             url = \"{url}\"\n"
795        ),
796        OriginSpec::Fs(path) => {
797            let path = path
798                .to_str()
799                .context("--origin file:// path is not valid UTF-8, so it cannot be written to a TOML config")?;
800            format!(
801                "[cache.origin]\n\
802                 kind = \"fs\"\n\
803                 path = {}\n",
804                toml_basic_string(path)?
805            )
806        }
807        OriginSpec::S3 { bucket } => {
808            let (bucket_line, edit_note) = match bucket {
809                Some(b) => (format!("bucket = {}", toml_basic_string(b)?), ""),
810                None => (
811                    "bucket = \"your-origin-bucket\"    # MUST-EDIT: your bucket name".to_string(),
812                    " Edit the MUST-EDIT bucket, then",
813                ),
814            };
815            format!(
816                "# S3-compatible origin (AWS S3 / Cloudflare R2 / Backblaze B2 / MinIO).{edit_note}\n\
817                 # confirm `region`; for non-AWS providers uncomment `endpoint_url` (and\n\
818                 # `path_style = true` for MinIO). Credentials default to the AWS chain\n\
819                 # (env / ~/.aws / IAM role) — uncomment [cache.origin.credentials] to override.\n\
820                 [cache.origin]\n\
821                 kind = \"s3\"\n\
822                 {bucket_line}\n\
823                 region = \"us-east-1\"                      # required even with a custom endpoint_url (SigV4 signing)\n\
824                 # endpoint_url = \"https://<accountid>.r2.cloudflarestorage.com\"  # for R2/B2/MinIO; omit for AWS\n\
825                 # path_style = true                         # required true for MinIO\n\
826                 # prefix = \"blobs/\"                         # optional key prefix; final key is {{prefix}}{{hex[0..2]}}/{{hex}}\n\
827                 # decompress = \"auto\"                       # \"auto\" decompresses gzip/zstd, \"strict\" refuses non-identity encodings\n\
828                 # [cache.origin.credentials]\n\
829                 # source = \"default-chain\"                  # or \"static\" with access_key_id = \"...\" and secret_access_key = \"...\"\n"
830            )
831        }
832    };
833    let block = format!(
834        "# --- Origin backend (written by `decdn config init --origin`) ---\n\
835         # An active origin backend makes this an ORIGIN node: the runtime derives\n\
836         # relay_foreign_namespaces = false (serve own namespaces only). Uncomment the\n\
837         # next line to override the derived value — set true to also relay foreign\n\
838         # content for pay while running an origin backend.\n\
839         # relay_foreign_namespaces = false\n\
840         {backend}\
841         \n"
842    );
843    Ok(format!("{head}{block}{tail}"))
844}
845
846/// Quote a value as a TOML basic string, escaping `\` and `"`.
847///
848/// Needed for values that did not come through `parse_origin_url`'s
849/// percent-encoding — a filesystem path or a bucket name — where a quote or
850/// backslash in the value would otherwise break out of the emitted string.
851/// Control characters (which TOML basic strings also forbid) are rejected
852/// rather than escaped: none has a legitimate place in a path or bucket name.
853fn toml_basic_string(value: &str) -> anyhow::Result<String> {
854    anyhow::ensure!(
855        !value.chars().any(char::is_control),
856        "value contains a control character, which cannot be written to a TOML config"
857    );
858    Ok(format!(
859        "\"{}\"",
860        value.replace('\\', "\\\\").replace('"', "\\\"")
861    ))
862}
863
864/// Trimmed TOML template `decdn config init --client` writes.
865///
866/// A client is a fetch-only consumer (`decdn fetch`, `decdn bundle pull`): it
867/// needs an identity, chain coordinates, and USDC to deposit — none of the
868/// cache/origin/serving sections a `decdn-node` daemon reads. The
869/// `client_config_template_is_trimmed_and_parses` guard keeps this template
870/// parsing against the live `FileConfig` schema with only the `[identity]`
871/// and `[blockchain]` sections present.
872const CLIENT_CONFIG: &str = r#"# deCDN client configuration — fetch-only consumer.
873# A client fetches and pays (`decdn fetch`, `decdn bundle pull`); it needs an
874# identity, chain coordinates, and USDC to deposit. The cache/origin/serving
875# sections a `decdn-node` daemon reads do not apply and are omitted.
876# CLI flags override values in this file.
877
878[identity]
879# data_dir = "~/.decdn/client"       # holds the eth keystore and the buyer-channel store
880
881[blockchain]
882# rpc_url = ""                       # REQUIRED: JSON-RPC URL for the payment chain
883# chain_id = 421614                  # EIP-712 chain id; default Arbitrum Sepolia
884# eth_keystore = "~/.decdn/client/keystore.json"   # defaults to <data_dir>/keystore.json; create with `decdn key-gen --output-dir ~/.decdn/client`
885# payment_pool_address = ""          # REQUIRED: 0x-prefixed hex; pool the client opens payment channels through
886# usdc_address = ""                  # USDC settlement token deposits spend
887# buyer_working_deposit_micro_usdc = 10000000  # deposit when OPENING a pool and refill target; must be > 0; default 10 USDC
888# buyer_max_approve = false          # exact deposit-sized USDC approvals (client default); true opts into an unlimited standing approval
889"#;
890
891/// Default TOML config file content, written by `decdn config init`.
892///
893/// This is the **canonical, fully-commented node config template** — the one
894/// place that enumerates every section and knob. There is no hand-maintained
895/// sample config beside it: `config init` (role flags + `--chain` presets) is
896/// the single source of a valid config. The one other hand-maintained copy
897/// (the e2e `render_config` template) is not derived from it, so CI guards
898/// against drift instead:
899///
900/// - `default_config_template_parses_with_every_section` — this template
901///   parses under `deny_unknown_fields` (no stale/typo'd section headers) and
902///   carries a header for *every* top-level `FileConfig` section, so adding a
903///   schema section without surfacing it here fails CI.
904/// - `render_config_emits_parseable_toml` (in `decdn_e2e`) — the e2e template
905///   parses as valid TOML and spot-checks the daemon-critical keys.
906///
907/// When adding a config knob, update this template first — the
908/// `default_config_template_covers_every_wired_field` guard below enforces
909/// field-level coverage for the daemon-config sections.
910const DEFAULT_CONFIG: &str = r#"# deCDN node configuration
911# CLI flags override values in this file.
912
913[identity]
914# data_dir = "~/.decdn"
915# region = "US"
916
917[network]
918# bind_port = 4433
919# Multiple relays give redundancy/failover; the node logs whether a relay connected
920# after bring-up but never fails on it (iroh retries in the background).
921# relay_urls = ["https://relay-a.example.", "https://relay-b.example."]
922# Operator-configurable address discovery (#818). Absent => n0-hosted pkarr/DNS.
923# Present => the node drops the n0 discovery leg and uses only what is set here.
924# [network.discovery]
925# pkarr_url = "https://pkarr.example./"      # publish this node's signed address record here
926# dns_origin = "discovery.example."          # resolve peers via DNS TXT under this origin
927# Static peer address book (fully offline; keyed by NodeId — 64-char lowercase hex):
928# [network.discovery.peers.0000000000000000000000000000000000000000000000000000000000000000]
929# relay_url = "https://relay.example./"
930# addrs = ["203.0.113.4:4433"]
931
932[blockchain]
933# rpc_url = ""                       # REQUIRED: Arbitrum Sepolia JSON-RPC URL
934# eth_keystore = "~/.decdn/keystore.json"
935# payment_pool_address = ""          # REQUIRED: 0x-prefixed hex
936# capacity_bond_address = ""        # REQUIRED: 0x-prefixed hex
937# origin_assignment_address = ""     # OPTIONAL: 0x-prefixed hex; `decdn publish assign` target, and the chain-backed origin directory for cache-miss pull-through fallback (ADR 022).
938# origin_directory_positive_ttl_sec = 300  # lazy origin directory: how long a resolved, non-empty getOrigins() set is served before a re-read; only consulted when origin_assignment_address is set; 0 disables caching; default 300s
939# origin_directory_negative_ttl_sec = 30   # lazy origin directory: how long "namespace has no origins" is cached — the DoS bound against permissionless/free namespace creation; only consulted when origin_assignment_address is set; 0 disables caching; default 30s
940# origin_directory_cache_capacity = 4096   # lazy origin directory: max distinct namespaces held (LRU eviction); only consulted when origin_assignment_address is set; default 4096
941# publisher_registry_address = ""    # OPTIONAL: 0x-prefixed hex; `decdn publish namespace create` target (#1029).
942# slash_judge_address = ""           # REQUIRED: 0x-prefixed hex (EIP-712 verifyingContract, ADR 014)
943# content_blacklist_address = ""     # REQUIRED: 0x-prefixed hex; deployed ContentBlacklist (ADR 011/031). Absent => startup fails before any ALPN accepts; the zero address is rejected (it is a fail-open compliance trap).
944# content_blacklist_poll_interval_sec = 600  # blacklist watcher periodic replay + re-scope cadence (ADR 011); must be > 0; default 600s
945# chain_staleness_grace_sec = 1800   # stop serving after this long without a successful chain read (ADR 011); guards deny-set/pool/signer-cap staleness; must be > 0; set large to opt out; default 1800s
946# chain_id = 421614                  # EIP-712 chain id; default Arbitrum Sepolia
947# rpc_watchdog_interval_sec = 30     # 0 disables the connectivity watchdog
948# event_poll_interval_ms = 7000      # eth_getLogs tick cadence for chain watchers + pending-tx receipt polling (#1011/#1106); default 7000ms, min 250ms (lower for a local anvil)
949# get_logs_max_block_span = 10000    # max blocks per chain-watcher eth_getLogs request; set to your RPC provider's range limit (the poller also shrinks the window when the provider rejects a range; set it to the lowest span the poller reaches while rejections keep coming); default 10000, must be > 0
950# fee_shares_poll_interval_sec = 3600  # authoritative getShares() re-read cadence, safety net beside the SharesUpdated subscription (ADR 041); default 3600s, must be > 0
951# redeem_threshold_micro_usdc = 1000000          # seller redeems accrued vouchers on-chain at this µUSDC balance (#327); default 1 USDC
952# redeem_max_vouchers_per_tx = 300  # max vouchers per redeemMany tx; the redeemer chunks a sweep to stay under the block gas limit (default 300)
953# redeem_interval_secs = 300                      # redeemer self-tick sweep cadence, the backstop beside the per-voucher hints (#327/#751); default 300s, must be > 0
954# buyer_working_deposit_micro_usdc = 10000000    # deposit when OPENING a pool and refill target on reuse or mid-transfer shortfall; must be > 0; default 10 USDC
955# buyer_max_approve = true                       # unlimited USDC approval for PaymentPool (#744); node default true, decdn client default false (exact deposit-sized approval); set true on the client to opt into unlimited
956# pool_min_remaining_deposit_micro_usdc = 1000000 # refundable floor M the node keeps in reserve on a pool it serves (ADR 003 § Sizing); default 1 USDC
957# pool_floor_signer_live_windows = 8              # per-signer LIVE concurrency cap k, in ramp-start credit windows: the most live un-vouchered floor reservation one capability signer may hold against a pool, k × one window (ADR 003 § Pool solvency); recycles as streams pay; lower-clamped to one window; default 8
958# CLI-only [blockchain] keys — consumed by `decdn appeal` / the client, NOT the daemon.
959# They live here because [blockchain] denies unknown fields and a node's node.toml is
960# shared with those CLIs, so a config that drives them must still pass `config validate`.
961# slash_appeal_address = ""          # `decdn appeal slash` target (ADR 028)
962# usdc_address = ""                  # USDC settlement token clients spend on pool deposits (client resolves it from PaymentPool.usdc() at runtime)
963
964[cache]
965# cache_dir = "~/.decdn/cache"
966# cache_size_mb = 102400                  # UPPER bound on the cache; the eviction driver clamps it down to keep disk_headroom_mb free on the volume (#1930). Set it large to let free disk size the cache.
967# disk_headroom_mb = 8192                  # free disk (MiB) the eviction driver keeps unused on the cache_dir volume, defended against ANY process (#1930); default 8 GiB. 0 opts out (only cache_size_mb binds)
968# max_blob_size_mb = 51200             # largest single blob admitted; unset => 50 GB capped to cache_size_mb. Must be <= cache_size_mb; 0 = unlimited
969# max_rate_per_mb = 0                      # buyer-side per-MB rate ceiling for paid cache-miss pulls (USDC base units); 0 = unlimited (#1375). Refuses a provider quote above the lower of this and the candidate's probe rate, before paying. A buyer-side ceiling only; the node never bounds its own sell rate
970# pinned_hashes = []                       # blob hashes (hex) exempted from LRU eviction (#276)
971# user_agent = "decdn-node/<version>"      # User-Agent on HTTP origin pull-through (#435); default embeds the crate version
972# Pull-through origin (singular). Mutually exclusive with the plural [[cache.origins]] form below.
973# Empty => no pull-through; cache misses return NoOrigin.
974# [cache.origin]
975# kind = "http"
976# url = "https://origin.example/"
977# decompress = "auto"                      # optional; "auto" decompresses gzip/zstd, "strict" refuses non-identity encodings
978# — or a local filesystem origin (blobs at {path}/{hex[0..2]}/{hex}):
979# [cache.origin]
980# kind = "fs"
981# path = "/var/lib/decdn/origin"
982# — or an S3-compatible origin (AWS S3 / Cloudflare R2 / Backblaze B2 / MinIO):
983# [cache.origin]
984# kind = "s3"
985# bucket = "decdn-blobs"
986# region = "us-east-1"                      # required even with a custom endpoint_url (SigV4 signing)
987# endpoint_url = "https://<accountid>.r2.cloudflarestorage.com"  # for R2/B2/MinIO; omit for AWS
988# path_style = true                         # required true for MinIO; AWS/R2 default to virtual-hosted-style
989# prefix = "blobs/"                         # optional key prefix; final key is {prefix}{hex[0..2]}/{hex}
990# decompress = "auto"                       # optional; mirrors the HTTP origin's knob
991# [cache.origin.credentials]               # omit entirely to use the AWS default credential chain (env / ~/.aws / IAM role)
992# source = "default-chain"                  # or "static" with access_key_id = "..." and secret_access_key = "..."
993# Multi-origin fallback (#284), tried in order on a miss:
994# [[cache.origins]]
995# kind = "http"
996# url = "https://primary.example/"
997# Origin pull-through retry policy (#285); restart-required.
998# [cache.origin_retry]
999# max_retries = 3
1000# Per-origin circuit breaker (#963); restart-required. Fronts the retry loop: after
1001# failure_threshold consecutive origin-unavailable failures it trips OPEN and fast-fails
1002# every miss for cooldown_ms, then admits half_open_max_calls trial pulls. Structurally
1003# identical to [cache.origin_retry]. Set enabled = false (or failure_threshold = 0) to opt out.
1004# [cache.circuit_breaker]
1005# enabled = true
1006# failure_threshold = 5
1007# cooldown_ms = 30000
1008# half_open_max_calls = 1
1009# NOTE: the keys below belong to [cache], NOT to the [cache.origin_retry] table
1010# above — uncomment this header along with them or TOML will nest them wrongly.
1011# [cache]
1012# gc_interval_sec = 300                    # iroh-blobs GC sweep cadence; 0 disables (#518). NOTE: the eviction driver only drops GC protection, so with 0 it can never reclaim disk and cache_size_mb is unenforceable (#1173)
1013# fs_rescan_interval_sec = 60              # re-walk the fs origin + re-check pins into the origin-held index, so a file dropped into the origin becomes probe-answerable and DHT-announced within one interval (#1130); 0 disables the timer (startup and `decdn node reload` still rescan)
1014# origin_probe_ttl_sec = 15                # TTL for a memoised positive live-origin probe answer — a hash absent from the fs/pins index falls back to a HEAD/HeadObject against the http/s3 origin, a present answer cached this long (#1130 pt3)
1015# origin_probe_negative_ttl_sec = 2        # TTL for a memoised negative (absent) live-origin probe answer; short on purpose so a stale absent cannot hide newly-available own content for long (#1130 pt3)
1016# origin_probe_fault_ttl_sec = 5           # TTL for a memoised fault (backend outage) live-origin probe answer; the memo is per hash, so this collapses one hash's repeats rather than a namespace's load. Must satisfy origin_probe_negative_ttl_sec <= this <= origin_probe_ttl_sec: patient enough to beat the negative TTL on a retried hash, eager enough that a recovered origin stops refusing clients soon (#1130 pt3, #1789 item 6)
1017# origin_probe_timeout_ms = 2000           # per-probe ceiling on the live-origin HEAD/HeadObject; on timeout the probe answers has_blob:false and the miss is memoised absent for one negative TTL (#1130 pt3)
1018# origin_probe_memo_capacity = 4096        # max distinct hashes held in the live-origin probe memo; bounds memo memory under a random-hash probe flood (#1130 pt3)
1019# eviction_high_water_pct = 90             # LRU driver evicts above this % of cache_size_mb (#1173); bounds [60,95]
1020# eviction_target_pct = 80                 # LRU driver evicts down to this % (#1173); bounds [40,90], must be <= high_water-5
1021# eviction_per_sweep_budget = 16           # max LRU victims per tick before yielding (#1173); bounds [1,256]
1022# eviction_tick_secs = 1                   # LRU driver wakeup cadence in seconds (#1173); bounds [1,60]
1023# max_probe_holds = 256                    # probe eviction-hold budget (ADR 005 §Hold budget); 0 disables has_blob:true
1024# stake_lane_reserved_holds = 0            # hold slots reserved for node-to-node probes (#757, ADR 003 §Admission); 0 = off
1025# node_to_node_pull_through_enabled = false # paid cache-miss pull from upstream nodes (#831, ADR 001/022); OFF by default
1026# relay_foreign_namespaces = true          # relay content this node's own backend does not hold (#1759); default is role-derived — origin-only (false) when a [cache.origin]/[[cache.origins]] backend is configured, relay (true) when none is; set explicitly to override
1027# node_pull_probe_fanout = 5               # providers probed before ranking on a node-to-node pull (#831)
1028# node_pull_timeout_sec = 20               # per-upstream STREAM-OPEN timeout (connect/handshake/response) on a node-to-node miss; NOT the channel open, which has its own 5s budget. The overall pull-through deadline is derived from this, the channel-open budget, and the stall window, so every ranked upstream can be tried before falling back (#831, #859)
1029# node_pull_stall_window_sec = 20          # per-upstream THROUGHPUT-FLOOR window while streaming (#1797); bytes are counted off the stream sub-frame, so it trips only when throughput falls below the floor — not on a large blob or a big frame. Budgeted per candidate, so raising it raises the worst-case client wait ~3x (167.5s at defaults); also the head start of each own-origin range-pull read's time budget (ADR 037)
1030# node_pull_min_throughput_bps = 4096      # minimum sustained upstream throughput (bytes/sec) over node_pull_stall_window_sec (#1797); catches a slow-drip wedge. 0 = idle detection only (one byte per window). The abort is requester-local and does not score the upstream's reputation; also the average throughput each own-origin range-pull read ({H}.obao4 or one data window) must sustain after that head start, 0 = those reads unbounded
1031# eviction_policy = "lru"                  # ADR 040: "lru" or "tinylfu"; restart-required
1032# admission_policy = "always"              # ADR 040: "always" or "tinylfu"; restart-required
1033# [cache.tinylfu]                          # W-TinyLFU tuning (ADR 040); promotion_threshold/probation_target_pct apply only under a "tinylfu" selector, but sketch_bytes also sizes the serve_economics "margin" heat estimator (the default), so it is live on a stock node
1034# sketch_bytes = 262144                    # count-min sketch size, shared by admission and eviction; minimum 16384
1035# promotion_threshold = 2                  # prior sightings before a probation member promotes to main
1036# probation_target_pct = 10                # % of cache_size_mb the probation segment is capped to; bounds [1,50]
1037# aging_halflife_sec = 600                 # reserved: not yet consulted by the shipped sketch (fixed observation-count window)
1038# [cache.serve_economics]                  # refuse-to-serve economics (ADR 041)
1039# policy = "margin"                        # "off" or "margin"
1040# discount = 0.5                           # (0.0, 1.0]; discount applied to the sell price for the margin gate
1041# n_max = 64                               # max concurrent speculative-warming sources; >= 1
1042# warming_budget = 5000000                 # per-source warming allowance, in payment base units; > 0
1043# warming_refill = 58                      # per-source allowance refill rate, base units/sec; 0 disables time-based refill
1044
1045[payment]
1046# rate_per_mb = 10
1047# credit_max = 67108864                    # downstream credit-window ceiling in bytes (ADR 003 §Credit window); the window ramps toward this cap as the stream pays; default 64 MiB; floored at one chunk (1 MiB)
1048# credit_ramp_divisor = 2                  # ramp divisor (ADR 003 §Credit window); window is paid/credit_ramp_divisor, capped at credit_max; 0 opens the full ceiling immediately
1049# frame_target_bytes = 1048576              # serve-path wire-frame target in bytes (ADR 005 §cdn/client/v1); node-local, never negotiated; clamped down to the credit window's remaining room; must be in 1..=1048576 (one payment chunk); default 1 MiB
1050# voucher_commit_interval_ms = 5000        # background flush period (ms) for durable voucher persistence (ADR 003); must be > 0; default 5000 (5s)
1051
1052[observability]
1053# log_level = "info"
1054# log_format = "pretty"
1055# metrics_port = 9090
1056# metrics_bind = "127.0.0.1"               # IP the metrics HTTP server binds; default loopback only
1057# admin_port = 9191                        # loopback-only; 0 disables (appendix-local-admin-http.md)
1058# otlp_endpoint = "http://localhost:4317"  # OTLP gRPC collector (http:// only); enables span export
1059
1060[security]
1061# max_concurrent_handlers = 256             # global cap on in-flight QUIC handler tasks; 0 disables the cap
1062# per_source_rate_per_sec = 100.0           # per-source rate-limit refill (cells/sec); 0.0 disables the layer
1063# per_source_burst = 200                    # per-source burst capacity; required > 0 when the rate is > 0
1064# max_tracked_sources = 4096                # cap on tracked sources in the keyed limiter; 0 = unbounded
1065
1066[load_shed]
1067# policy = "resource-pressure"              # "resource-pressure" (default) or "always-admit"
1068# egress_budget_mbps = 0                    # serving egress budget in Mbps; 0 disables the egress ceiling
1069# max_concurrent_serves_high = 256          # concurrency high-water mark; start shedding misses at/above
1070# max_concurrent_serves_low = 192           # concurrency low-water mark; resume at/below
1071# per_client_serve_cap = 32                 # per-client concurrent-serve cap under pressure; 0 disables
1072
1073[dht.rate_limit]
1074# per_peer_rate_per_sec = 20.0              # per-peer (NodeId) sustained rate (ADR 022); 0.0 disables the layer
1075# per_peer_burst = 40                       # per-peer burst capacity; required > 0 when the rate is > 0
1076# per_ip_rate_per_sec = 100.0               # per-IP sustained rate; 0.0 disables
1077# per_ip_burst = 200                        # per-IP burst capacity
1078# global_rate_per_sec = 1000.0              # global inbound DHT sustained rate; 0.0 disables
1079# global_burst = 2000                       # global inbound DHT burst capacity
1080# max_tracked_per_ip = 4096                 # cap on the per-IP keyed-limiter map (#645); 0 = unbounded
1081# max_tracked_per_peer = 4096               # cap on the per-peer keyed-limiter map (#645); 0 = unbounded
1082
1083[probe.rate_limit]
1084# per_peer_rate_per_sec = 5.0               # per-peer (NodeId) sustained rate (ADR 005); 0.0 disables the layer
1085# per_peer_burst = 5                        # per-peer burst capacity; required > 0 when the rate is > 0
1086# per_ip_rate_per_sec = 50.0                # per-IP sustained rate; 0.0 disables
1087# per_ip_burst = 200                        # per-IP burst capacity
1088# global_rate_per_sec = 1000.0              # global inbound probe sustained rate; 0.0 disables
1089# global_burst = 2000                       # global inbound probe burst capacity
1090# max_tracked_per_ip = 4096                 # cap on the per-IP keyed-limiter map (#645); 0 = unbounded
1091# max_tracked_per_peer = 4096               # cap on the per-peer keyed-limiter map (#645); 0 = unbounded
1092
1093[receipts]
1094# max_file_bytes = 134217728                # rotate the download-receipt log at this size (#802); default 128 MiB
1095# retained_files = 4                        # rotated backup receipt files retained (#802); 0 keeps none
1096
1097[content]
1098# ADR 011 local denylist — this operator's own removal lever, independent of
1099# governance. Entries take effect on `decdn node reload` (no restart) and bind
1100# only this node. This is the fastest removal path the protocol offers and the
1101# one sized to a sub-day statutory deadline (e.g. the EU TCO one-hour clock),
1102# because it is entirely within the order recipient's control. Refused
1103# requests are signed as Declined, which does not reveal whether the entry is
1104# local or on-chain.
1105#
1106# Hashes are bare 64-char lowercase hex — the same spelling as
1107# cache.pinned_hashes. An invalid entry FAILS startup rather than being skipped:
1108# a typo in a takedown must not silently leave content served.
1109# denied_hashes = ["0000000000000000000000000000000000000000000000000000000000000000"]
1110# denied_origins = ["0x000000000000000000000000000000000000dEaD"]   # operator addresses whose channels are refused (the zero address is rejected)
1111
1112[client]
1113# `decdn fetch`/`decdn bundle pull` discovery pre-filter (independent of the
1114# node-scoped [network.discovery] address discovery above). Narrows which
1115# registered nodes are probed/discovered; never ranks them. Absent/empty =>
1116# no filter, every registered node is eligible.
1117# region_allowlist = ["US", "DE"]
1118"#;
1119
1120#[cfg(test)]
1121#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] // tests
1122mod tests;