From 8422a015dd9546bb76b3b81d9efe9920f304b7bc Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 9 Sep 2026 09:02:24 -0400 Subject: [PATCH] feat(config): apply the [capacity] max_accounts section CapacitySection parsed but reached nothing -- the same silent-ignore gap issue 3murx26m6xs2r names for eight other sections. Config::capacity now exists, and --max-accounts/resolve_max_accounts thread it through the same didbot_config::precedence order --max-blob and [blobs] already use, so a value in didbot.toml actually reaches AuthState. Change-Id: I7001d4cb55d50d2e0563f5d119e21d220ab314df --- crates/didbot-config/src/lib.rs | 7 +- crates/didbot-serve/src/bin/didbot-pds.rs | 80 ++++++++++++++++++++++- plan/config.md | 2 + 3 files changed, 85 insertions(+), 4 deletions(-) diff --git a/crates/didbot-config/src/lib.rs b/crates/didbot-config/src/lib.rs index c64bfd2d..e7fda8ce 100644 --- a/crates/didbot-config/src/lib.rs +++ b/crates/didbot-config/src/lib.rs @@ -87,8 +87,8 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use sections::{ - BlobsSection, DisclosureSection, DnsSection, EstopSection, LimitsSection, NamesSection, - PolicySection, RelaySection, ServerSection, TlsSection, ZoneSection, + BlobsSection, CapacitySection, DisclosureSection, DnsSection, EstopSection, LimitsSection, + NamesSection, PolicySection, RelaySection, ServerSection, TlsSection, ZoneSection, }; /// The whole file, one optional table per section. @@ -115,6 +115,9 @@ pub struct Config { /// `[blobs]` — quota, max size, retention window. #[serde(default)] pub blobs: Option, + /// `[capacity]` — how many accounts this deployment may hold. + #[serde(default)] + pub capacity: Option, /// `[names]` — the naming spec, its fallback, and the hold period. #[serde(default)] pub names: Option, diff --git a/crates/didbot-serve/src/bin/didbot-pds.rs b/crates/didbot-serve/src/bin/didbot-pds.rs index 90421ade..b24d044f 100644 --- a/crates/didbot-serve/src/bin/didbot-pds.rs +++ b/crates/didbot-serve/src/bin/didbot-pds.rs @@ -142,6 +142,10 @@ struct Args { blob_quota: Option, /// `--blob-collection-grace`, in hours, if given. Same reasoning. blob_collection_grace_hours: Option, + /// `--max-accounts`, if given. Same reasoning as `max_blob`: left unset + /// so a config file's `[capacity]` can fill it in; `run` resolves the + /// final value against `AuthState::default`'s own built-in ceiling. + max_accounts: Option, /// Where the e-stop's file latch is watched. Absent means this run has /// no file latch, only the admin socket. estop_file: Option, @@ -255,6 +259,7 @@ impl Default for Args { max_blob: None, blob_quota: None, blob_collection_grace_hours: None, + max_accounts: None, estop_file: None, estop_socket: Some(estop_admin::default_socket_path()), disclosure: Disclosure::default(), @@ -291,8 +296,8 @@ usage: didbot-pds [options] --config a sectioned TOML file to read settings from -- see didbot-config and plan/config.md. Only [blobs], - [disclosure] and [relay] are read today; every other - section parses (an unknown key anywhere in the file + [capacity], [disclosure] and [relay] are read today; every + other section parses (an unknown key anywhere in the file refuses startup) but is not yet consulted. A flag always wins over the file for the same setting. --port listen port (default 3000) @@ -350,6 +355,10 @@ usage: didbot-pds [options] 1 hour is refused at startup rather than accepted and quietly non-conformant -- see . + --max-accounts + how many accounts this deployment may hold before + bot.did.provisionAgent refuses to mint another (default + 1000; see plan/capacity.md). --estop-file watch as the e-stop's file latch: its presence engages a stop, and its content (`pause` or `revoke`) @@ -509,6 +518,21 @@ fn resolve_blobs(args: &Args, config: Option<&didbot_config::Config>) -> BlobLim } } +/// Resolves the account cap this run applies: command line, then the config +/// file's `[capacity]` section, then [`AuthState::default`]'s own built-in +/// ceiling -- `didbot_config::precedence`'s order, the same as +/// [`resolve_blobs`]. +fn resolve_max_accounts(args: &Args, config: Option<&didbot_config::Config>) -> u64 { + didbot_config::precedence( + args.max_accounts, + config + .and_then(|c| c.capacity.as_ref()) + .and_then(|c| c.max_accounts), + None, + ) + .unwrap_or(AuthState::default().max_accounts) +} + /// Resolves the relay the admin socket's announce commands reach: command /// line, then the config file's `[relay]` section -- /// `didbot_config::precedence`'s order. `None` either way means those @@ -813,6 +837,13 @@ fn parse_args>(mut args: I) -> Result { })?; parsed.blob_collection_grace_hours = Some(hours); } + "--max-accounts" => { + let raw = value()?; + parsed.max_accounts = Some( + raw.parse() + .map_err(|_| format!("--max-accounts: `{raw}` is not a number"))?, + ); + } "--estop-file" => parsed.estop_file = Some(PathBuf::from(value()?)), "--estop-socket" => { let raw = value()?; @@ -912,6 +943,7 @@ async fn run(mut args: Args) -> Result<(), String> { let config = load_config(args.config.as_ref())?; apply_disclosure_config(&mut args.disclosure, config.as_ref()); let blobs = resolve_blobs(&args, config.as_ref()); + let max_accounts = resolve_max_accounts(&args, config.as_ref()); let relay_hostname = resolve_relay_hostname(&args, config.as_ref()); // Every configured zone, validated in the order given. The first is the @@ -1224,6 +1256,7 @@ async fn run(mut args: Args) -> Result<(), String> { estop: Arc::new(Estop::new(args.estop_file.clone())), disclosure: args.disclosure, oauth, + max_accounts, trust_forwarded_headers: args.trust_forwarded_headers, ..AuthState::default() }; @@ -2033,6 +2066,34 @@ mod config_tests { assert_eq!(resolve_relay_hostname(&Args::default(), None), None); } + #[test] + fn a_configured_account_cap_reaches_the_resolved_value() { + let dir = TempDir::new("capacity-from-file"); + let path = dir.write("didbot.toml", "[capacity]\nmax_accounts = 5\n"); + let config = load_config(Some(&path)).unwrap(); + assert_eq!(resolve_max_accounts(&Args::default(), config.as_ref()), 5); + } + + #[test] + fn cli_max_accounts_beats_the_file() { + let dir = TempDir::new("capacity-precedence"); + let path = dir.write("didbot.toml", "[capacity]\nmax_accounts = 5\n"); + let config = load_config(Some(&path)).unwrap(); + let args = Args { + max_accounts: Some(2), + ..Args::default() + }; + assert_eq!(resolve_max_accounts(&args, config.as_ref()), 2); + } + + #[test] + fn with_neither_flag_nor_file_the_built_in_cap_applies() { + assert_eq!( + resolve_max_accounts(&Args::default(), None), + AuthState::default().max_accounts + ); + } + #[test] fn the_relay_flag_beats_the_file() { let dir = TempDir::new("relay-precedence"); @@ -2105,6 +2166,21 @@ mod config_tests { assert_eq!(args.blob_quota, Some(456)); } + #[test] + fn parse_args_reads_max_accounts() { + let args = parse_args(["--max-accounts", "42"].into_iter().map(str::to_owned)).unwrap(); + assert_eq!(args.max_accounts, Some(42)); + } + + #[test] + fn a_non_numeric_max_accounts_refuses_naming_the_flag() { + let err = match parse_args(["--max-accounts", "lots"].into_iter().map(str::to_owned)) { + Ok(_) => panic!("a non-numeric --max-accounts should have been refused"), + Err(err) => err, + }; + assert!(err.contains("--max-accounts"), "error: {err}"); + } + /// A `Route53Dns` built and never resynced starts believing the zone is /// empty, and its local bookkeeping is what `withdraw`/`withdraw_txt` /// consult before deciding a record exists — so a fresh process after a diff --git a/plan/config.md b/plan/config.md index 44c5154b..f39b330c 100644 --- a/plan/config.md +++ b/plan/config.md @@ -68,6 +68,8 @@ and from `didbot-serve/src/routes.rs`'s constants rather than guessed: under the same name rather than inventing a second one). Wired, except `collection_grace_secs`, which is wired now, against `--blob-collection-grace-hours` to wire it to yet. +- **`[capacity]`** — `max_accounts`, against `--max-accounts` + (`plan/capacity.md`). Wired. - **`[names]`** — the naming spec or template, the fallback namer, the hold period. - **`[limits]`** — rate limits and their windows, once -- 2.51.2