Files
buzz/crates/buzz-relay/src/config.rs
Brad Seiler a1f79e3aba feat(relay): persist NIP-FI upgrades (BUZZ-510) (#8985)
## Summary

Record that a community requires NIP-FI and provide an idempotent
operator API to enable it and recover the original receipt. Acceptance
checks the current owner, host, lifecycle and existing issuer trust in
one transaction. It never creates trust. Receipts survive community
deletion so an operation UUID cannot be reused.

New acceptances default to disabled through
`BUZZ_NIP_FI_UPGRADES_ENABLED`. Disabled calls return HTTP 503
`upgrades_disabled`; identical retries and authenticated readback still
recover existing receipts. Policy is permanent: stopping new calls or
rolling back code does not clear it. Last-issuer removal is refused for
required communities, and bulk backfill leaves missing bindings for
explicit operator repair.

Apply additive migration 0082 before code. Existing communities remain
optional. This layer records acceptance and reports only `pending` or
`unavailable`; deploy and verify [per-request
enforcement](https://github.com/block/buzz/pull/8988) and [session
activation](https://github.com/block/buzz/pull/8991) before enabling
acceptance. The global enforce floor remains. See [the operator
contract](https://github.com/block/buzz/blob/seiler/BUZZ-510/relay-auth-upgrade/docs/community-auth-upgrades.md)
for signing, recovery and containment.

### Related issue


[BUZZ-510](https://linear.app/squareup/issue/BUZZ-510/relay-persist-community-nip-fi-requirements-and-expose-idempotent).
Base of native stack `8989`; retains trunk's enforcement telemetry from
[#8974](https://github.com/block/buzz/pull/8974). Includes a separate
commit that repairs an observer timing flake in a desktop test required
by the repository gate.

### Testing

Controlled local regressions detect the old disabled error code and
removal of the last issuer with `allow_last=true`; intended source is
restored. Full PostgreSQL validation and the updated live relay/admin
exercise remain pending. Human acceptance remains open. No deployment or
production activation.

Generated with Codex

---------

Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
2026-11-04 19:56:07 +00:00

3127 lines
130 KiB
Rust

//! Relay configuration from environment variables.
use std::time::Duration;
use std::{collections::HashMap, net::SocketAddr};
use sha2::{Digest, Sha256};
use thiserror::Error;
use tracing::{error, warn};
/// Default maximum inbound WebSocket frame size in bytes.
///
/// Must comfortably exceed accepted event content sizes after Nostr JSON and
/// NIP-44 encryption overhead.
pub const DEFAULT_MAX_FRAME_BYTES: usize = 512 * 1024;
/// Errors that can occur while loading relay configuration.
#[derive(Debug, Error)]
pub enum ConfigError {
/// The `BUZZ_BIND_ADDR` environment variable could not be parsed as a socket address.
#[error("invalid BUZZ_BIND_ADDR: {0}")]
InvalidBindAddr(String),
/// A configuration value failed validation.
#[error("invalid config: {0}")]
InvalidValue(String),
}
/// Authentication mode for the deployment-admin API.
///
/// Configured by `BUZZ_ADMIN_AUTH`: unset/empty/`nip98` → `Nip98` (fail-secure
/// default), `disabled` → `Disabled`, anything else is a startup error.
///
/// # Role resolution (nip98 mode only)
///
/// In `nip98` mode the authenticated pubkey is resolved to an
/// `AdminPrincipal` at request time via [`crate::api::admin::auth::lookup_admin_principal`]:
/// - `Operator/Config` if pubkey ∈ `RELAY_OPERATOR_PUBKEYS`
/// - `Operator/OwnerFallback` if pubkey == `RELAY_OWNER_PUBKEY` **and**
/// `RELAY_OPERATOR_PUBKEYS` is empty (evaluated from config, never runtime rows)
/// - `Moderator/Db` from the `relay_operators` table otherwise
/// - `None` → 403 (no fall-through role, ever)
///
/// Disabled mode serves every moderation read and refuses writes and staffing.
/// NIP-98 mode is read-write per resolved principal.
#[derive(Debug, Clone)]
pub enum AdminAuth {
/// Authentication disabled. The operator has explicitly asserted
/// that the admin API is protected at the network layer (reverse proxy,
/// VPN, firewall). `Host`/`Origin` checks remain active as defense-in-depth.
/// Selected by `BUZZ_ADMIN_AUTH=disabled`.
/// Every moderation read is served to anyone who can reach the relay;
/// `authorize()` returns `NetworkTrusted` with no identity, so write and
/// staffing routes always 403.
Disabled,
/// NIP-98 HTTP Auth. Every request must carry an `Authorization: Nostr`
/// header containing a signed kind-27235 event. The authenticated pubkey
/// is resolved to an [`crate::api::admin::auth::AdminPrincipal`] at request
/// time from config + DB. Selected by `BUZZ_ADMIN_AUTH=nip98` or by leaving
/// `BUZZ_ADMIN_AUTH` unset (fail-secure default). Read-write per resolved
/// principal; attributes mutations to a distinct human operator.
Nip98,
}
/// Deny-by-default deployment-admin configuration. Mutation and staffing routes
/// require a resolved principal (NIP-98 only); disabled mode serves reads only.
#[derive(Debug, Clone)]
pub struct AdminConfig {
/// Exact admin HTTP authority.
pub host: String,
/// Authentication mode selected at startup.
pub auth: AdminAuth,
/// Optional admin SPA bundle directory.
pub web_dir: Option<std::path::PathBuf>,
}
/// Relay-hosted policy content presented on join surfaces.
#[derive(Debug, Clone)]
pub struct JoinPolicyConfig {
/// Operator-provided Terms of Service document in Markdown.
pub terms_markdown: Option<String>,
/// Operator-provided Privacy Policy document in Markdown.
pub privacy_markdown: Option<String>,
/// Whether join surfaces must collect an 18+ attestation.
pub age_attestation_required: bool,
/// Content-derived identifier binding receipts to the exact policy revision.
pub version: String,
}
/// Optional KLIPY GIF-search integration owned by the relay operator.
///
/// The API key deliberately stays private and its [`Debug`] implementation is
/// redacted so dumping [`Config`] cannot disclose it.
#[derive(Clone)]
pub struct KlipyConfig {
api_key: String,
}
impl KlipyConfig {
/// Return the key only to the outbound KLIPY client.
pub(crate) fn api_key(&self) -> &str {
&self.api_key
}
}
impl std::fmt::Debug for KlipyConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("KlipyConfig")
.field("api_key", &"[REDACTED]")
.finish()
}
}
/// Maximum configured jitter, leaving ten seconds of the hard-drain budget for
/// WebSocket close-frame delivery after the final delayed cancellation.
pub const MAX_DRAIN_JITTER_MS: u64 = 20_000;
/// Relay runtime configuration, loaded from environment variables.
#[derive(Debug, Clone)]
pub struct Config {
/// Address the relay HTTP/WebSocket server binds to.
pub bind_addr: SocketAddr,
/// Postgres database connection URL.
pub database_url: String,
/// Optional read-replica connection URL (e.g. an Aurora `cluster-ro-`
/// endpoint). Unset means all reads stay on the writer.
pub read_database_url: Option<String>,
/// Replica read budget `B` in milliseconds (`BUZZ_REPLICA_READ_MAX_AGE_MS`).
/// `0` (the default) disables bounded-staleness replica routing; see
/// [`buzz_db::DbConfig::replica_read_max_age_ms`].
pub replica_read_max_age_ms: u64,
/// Replica freshness budget for fleet usage telemetry
/// (`BUZZ_USAGE_METRICS_REPLICA_MAX_AGE_MS`). Independent of serving-read
/// routing; defaults to 30 seconds and `0` disables telemetry DB queries.
pub usage_metrics_replica_max_age_ms: u64,
/// Upper bound, in milliseconds, of the per-connection random delay applied
/// when sending the `1012 Service Restart` close frame during graceful
/// shutdown (`BUZZ_DRAIN_JITTER_MS`). Each live connection is closed after
/// an independent delay drawn uniformly from `[1, drain_jitter_ms]` when
/// jitter is enabled, which
/// spreads client reconnects across the window instead of releasing the
/// whole pod's sockets in one instant (the reconnect thundering herd that
/// drives DB pool-timeout bursts on rolling deploys).
///
/// Default `0` reproduces the previous all-at-once close. Values above
/// [`MAX_DRAIN_JITTER_MS`] are capped, leaving headroom under the relay's
/// 30-second hard-drain timeout for close-frame delivery.
pub drain_jitter_ms: u64,
/// Redis connection URL used by the pub/sub manager.
pub redis_url: String,
/// Maximum connections in the shared Redis pool. Defaults to 16.
///
/// deadpool's own default is `CPU_COUNT * 2`, which on a 2-vCPU relay
/// pod is only 4 — small enough that rate-limit checks, presence, and
/// pub/sub publishes queue behind each other under load.
pub redis_pool_size: usize,
/// Maximum connections in the Postgres writer/reader pools. Defaults to 50.
///
/// The `buzz-db` default of 20 was sized for a handful of pods against
/// `max_connections=100`. Against Aurora (~5,000 connections) that cap
/// is the binding constraint: a burst of concurrent handlers exhausts
/// the per-pod pool and requests fail on acquire timeout while the
/// database sits idle.
pub db_pool_size: u32,
/// Maximum connections in the Postgres read-replica pool
/// (`BUZZ_DB_READ_POOL_SIZE`). Defaults to `db_pool_size`. Sized
/// independently so reader capacity can be tuned against the replica's
/// headroom without touching the writer pool.
pub db_read_pool_size: Option<u32>,
/// Public WebSocket URL of this relay, advertised in NIP-11.
pub relay_url: String,
/// Public WebSocket URL of the dedicated device-pairing relay, when configured.
pub pairing_relay_url: Option<String>,
/// Maximum number of concurrent WebSocket connections.
pub max_connections: usize,
/// Maximum number of concurrently executing message handlers.
pub max_concurrent_handlers: usize,
/// Per-connection outbound message buffer size (number of messages).
pub send_buffer_size: usize,
/// Maximum inbound WebSocket frame size in bytes.
pub max_frame_bytes: usize,
/// Number of consecutive buffer-full events tolerated before cancelling a slow client.
pub slow_client_grace_limit: u8,
/// `BUZZ_CLIENT_IP_HEADER`: the request header whose IP access lines log
/// as `client_ip`. `None` logs only the TCP `peer_addr`.
pub client_ip_header: Option<axum::http::HeaderName>,
/// Authentication provider configuration.
pub auth: buzz_auth::AuthConfig,
/// Whether REST API requests must present a valid token. Independent of
/// WebSocket protocol auth, which is *always* required by REQ/EVENT/COUNT.
pub require_auth_token: bool,
/// Opt-in private accessory API; disabled until explicitly deployed.
pub buzz_v1_enabled: bool,
/// Author-time unread tracking duration, independent of NIP-RS retention.
pub buzz_v1_retention_seconds: u32,
/// Comma-separated list of allowed CORS origins.
/// If empty, permissive CORS is used (dev mode).
/// Example: "tauri://localhost,http://localhost:3000"
pub cors_origins: Vec<String>,
/// Optional hex-encoded private key for the relay's signing keypair.
/// If absent, a fresh keypair is generated at startup.
pub relay_private_key: Option<String>,
/// Optional Unix Domain Socket path. When set, the relay also listens on this
/// UDS for traffic (e.g. service mesh sidecar). Health probes still use TCP.
pub uds_path: Option<String>,
/// TCP port for the health-only router (`/_liveness`, `/_readiness`, `/_status`).
/// Separate from the app router so K8s probes bypass Istio and auth middleware.
pub health_port: u16,
/// TCP port for the Prometheus metrics exporter (`GET /metrics`).
pub metrics_port: u16,
/// Interval between read-only partition catalog audits.
pub partition_audit_interval: Duration,
/// Whether the partition manager may issue any partition DDL. Off also
/// disables catch-all advancement.
pub partition_manager_create_enabled: bool,
/// Whether the partition manager may replace an empty right-edge catch-all
/// with dedicated monthlies. Also enables periodic (not only startup) DDL.
/// On by default; requires `partition_manager_create_enabled`.
pub partition_manager_advance_enabled: bool,
/// When true, NIP-42 pubkey-only authentication (no API token) is
/// restricted to pubkeys in the `pubkey_allowlist` table. Users with valid
/// API tokens bypass the allowlist entirely.
/// Applies to all NIP-42 pubkey-only connections, regardless of `require_auth_token`.
pub pubkey_allowlist_enabled: bool,
/// When true, every authenticated request must also pass a relay-level
/// membership check against the `relay_members` table.
/// When false (default), the check is a no-op and all authenticated callers
/// are permitted regardless of auth method (API token, NIP-42).
pub require_relay_membership: bool,
/// Whether this deployment can serve huddle (voice) audio.
///
/// Huddle audio frames are relayed peer-to-peer *within a single pod*
/// (`AudioRoomManager` is an in-process map; only huddle lifecycle events
/// cross pods via Redis). Under horizontal scaling (any-pod-any-connection,
/// plan §4 fork B) two peers in the same huddle can land on different pods
/// and never hear each other. Rather than sticky-route huddles or ship a
/// silent split-room (plan §5b, decided by Tyler), a horizontally-scaled
/// deployment sets this `false` and the relay surfaces a clear, client-
/// handleable "huddle audio unavailable" signal on join.
///
/// Defaults to `true` so single-pod deployments (the N=1 case) keep today's
/// behavior unchanged. Operators running multiple relay pods MUST set
/// `BUZZ_HUDDLE_AUDIO_AVAILABLE=false` until the out-of-relay media/SFU
/// service lands.
pub huddle_audio_available: bool,
/// Inter-relay mesh configuration (`BUZZ_MESH`, `BUZZ_MESH_BIND_ADDR`).
/// Opt-in: mesh forms only when `BUZZ_MESH=on` is explicit. The default
/// (absent/off) is exact single-instance behavior — no bind, no Redis
/// registry write — so an image upgrade with untouched env is a strict
/// no-regression rollout.
pub mesh: buzz_relay_mesh::MeshConfig,
/// Testbed-only reliable-stream echo consumer (`BUZZ_MESH_DEMO_ECHO`).
/// When `on`, the owner side of an inbound reliable mesh stream echoes
/// every validated `Data` frame back to the sender — a transport/
/// session-routing smoke for cross-pod evidence runs, NOT a product flow.
/// Same strict opt-in as `BUZZ_MESH`; default off means inbound reliable
/// streams are accepted, logged, and closed (no session consumer yet).
pub mesh_demo_echo: bool,
/// Optional hex-encoded pubkey of the relay owner.
/// When set, this pubkey is automatically bootstrapped into `relay_members`
/// with the `owner` role on first startup.
pub relay_owner_pubkey: Option<String>,
/// Canonical HTTP origin of the deployment-global operator API.
///
/// Every operator NIP-98 `u` tag is verified against this origin, independent
/// of the inbound HTTP `Host` header and tenant registry. Required only to
/// *use* the community-provisioning endpoints: when it is unset, those
/// endpoints fail closed at request time (see
/// `api::operator::authorize_operator_request`). It is NOT required at boot
/// even when `RELAY_OPERATOR_PUBKEYS` is set, because that allowlist is
/// shared with the NIP-98 admin console, which needs no origin. Set via
/// `RELAY_OPERATOR_API_ORIGIN` as an `http://` or `https://` origin with no
/// path, query, or fragment.
pub relay_operator_api_origin: Option<String>,
/// Deployment-level relay operator pubkeys allowed to use the
/// `/operator/communities` management endpoints.
///
/// Unlike `relay_owner_pubkey` (a role *within* the deployment community),
/// operators span tenants: they may create new communities and bootstrap
/// initial owners, but hold no implicit tenant membership row.
/// Empty (the default) disables community provisioning entirely — fail closed.
///
/// Set via `RELAY_OPERATOR_PUBKEYS` as a comma-separated list of 64-char
/// hex pubkeys. Invalid entries are rejected at startup (config error), not
/// skipped — a typo must not silently disable an operator.
pub relay_operator_pubkeys: Vec<String>,
/// Configured operator-listener identities and their HTTP(S) delivery URLs.
pub operator_listener_delivery_urls: HashMap<String, url::Url>,
/// Endpoint receiving each new per-community storage snapshot
/// (`BUZZ_STORAGE_USAGE_URL`). Unset disables storage-usage delivery.
pub storage_usage_delivery_url: Option<url::Url>,
/// Allow NIP-OA owner attestation for relay membership.
///
/// When `true` and `require_relay_membership` is also `true`, agents
/// bearing a valid NIP-OA `auth` tag can authenticate by proving their
/// owner is a relay member. The agent gets session-scoped access.
///
/// On open relays (`require_relay_membership = false`), NIP-OA owner
/// extraction for agent→owner backfill happens unconditionally (the
/// signature is cryptographically self-proving). This flag only controls
/// whether NIP-OA can grant membership access on closed relays.
///
/// Default: `false`. Set via `BUZZ_ALLOW_NIP_OA_AUTH=true`.
pub allow_nip_oa_auth: bool,
/// Relay-owned KLIPY integration. Unset means GIF search is not advertised
/// and its proxy routes return 404.
pub klipy: Option<KlipyConfig>,
/// Media storage configuration (S3/MinIO).
pub media: buzz_media::MediaConfig,
/// Maximum concurrent media uploads handled by one relay process.
pub media_max_concurrent_uploads: usize,
/// Maximum concurrent media uploads accepted from one pubkey.
pub media_max_concurrent_uploads_per_pubkey: u32,
/// Maximum media upload starts accepted from one pubkey per minute.
pub media_uploads_per_minute: u32,
/// Whether tamper-evident event/media audit logging is enabled. Defaults to true.
/// This does not control the separate `moderation_actions` audit trail.
/// Set `BUZZ_AUDIT_ENABLED=false` for deployments that do not require it.
pub audit_enabled: bool,
/// Optional override for ephemeral channel TTL (in seconds).
/// When set, any channel created with a TTL tag will use this value instead
/// of the client-provided one. Useful for testing ephemeral expiry quickly.
/// Example: `BUZZ_EPHEMERAL_TTL_OVERRIDE=60` → all ephemeral channels expire
/// 60 seconds after the last message.
pub ephemeral_ttl_override: Option<i32>,
/// Root directory for the relay's local git scratch. No authoritative
/// repository state lives here — runtime reads/writes hydrate ephemeral
/// repos from object storage per request. Temporary workspaces, buffered
/// subprocess output, and the disposable immutable pack cache live below
/// this path.
/// Repo-name uniqueness lives in Postgres (`git_repo_names`), not on disk,
/// so this directory need not be persistent or shared across replicas.
pub git_repo_path: std::path::PathBuf,
/// Parent directory for process-isolated immutable pack cache sessions.
pub git_pack_cache_path: std::path::PathBuf,
/// Maximum pack file size for git push (bytes). Default: 500 MB.
pub git_max_pack_bytes: u64,
/// Maximum total bytes materialized for one git repo request. Default: 1 GB.
///
/// This bounds clone/fetch hydration work across a repo's historical pack
/// set rather than only bounding one incoming push body.
pub git_max_repo_bytes: u64,
/// Maximum bytes retained in the process-local immutable pack/index cache.
/// Zero disables retention while preserving request-local hydration.
pub git_pack_cache_max_bytes: u64,
/// Maximum pack digests populated concurrently in one relay process.
pub git_pack_cache_max_concurrent_populations: usize,
/// Maximum number of repos per pubkey. Default: 100.
pub git_max_repos_per_pubkey: u32,
/// Maximum concurrent git subprocess operations. Default: 20.
pub git_max_concurrent_ops: usize,
/// HMAC secret for git pre-receive hook callbacks.
/// Used to authenticate internal policy endpoint requests.
pub git_hook_hmac_secret: String,
/// Whether NIP-PL push discovery, lease acceptance, enqueueing, matching, and delivery
/// are enabled for this deployment. Defaults to false.
pub push_enabled: bool,
/// Descriptor key identifier accepted in kind:30350 `exec` tags.
pub push_executor_key_id: String,
/// Exact HTTP(S) gateway endpoint used to sign and submit APNs delivery capabilities.
/// Required while push is enabled. An explicitly empty setting is allowed
/// only while push is disabled.
pub push_gateway_delivery_url: Option<url::Url>,
/// Hard timeout for one push gateway delivery request.
pub push_gateway_timeout: Duration,
/// Hard timeout for one operator-listener delivery request.
pub operator_listener_timeout: Duration,
/// Optional relay-hosted policy shown on join surfaces. Disabled when no
/// documents or age attestation are configured.
pub join_policy: Option<JoinPolicyConfig>,
/// Absolute UTC Unix-second cutoff for legacy v1 invites; unset drains naturally.
pub invite_v1_invalid_after: Option<u64>,
/// Deployment-admin API and SPA configuration. Absent means the surface is disabled.
pub admin: Option<AdminConfig>,
/// Optional path to the web UI `dist/` directory.
/// When set, the relay serves the invite landing page and its static assets.
/// When unset, no static file serving happens (relay behaves as before).
pub web_dir: Option<std::path::PathBuf>,
/// Whether the configured web bundle serves Git browser routes in addition
/// to the public invite landing page. Defaults to false.
pub serve_git_web_gui: bool,
/// NIP-FI federated-identity enforcement configuration.
///
/// Selected by `BUZZ_NIP_FI_MODE`: `enforce` validates assertions at HTTP
/// ingress and (via S3) at WebSocket upgrade; `deny_protected` denies
/// protected requests without evaluating; `shadow` evaluates and records
/// the enforce verdict but admits exactly as `off`. `Off` (the default)
/// leaves all identity enforcement to NIP-42 alone.
pub nip_fi: crate::nip_fi_config::NipFiRelayConfig,
/// Issuer binding written to communities created under a host suffix
/// (`BUZZ_NIP_FI_PROVISION_BINDING`). `None` binds nothing at creation.
/// Parsed whatever the NIP-FI mode, since creation happens in every mode.
pub nip_fi_provision_binding: Option<buzz_auth::ProvisionBindingPolicy>,
/// Permit new irreversible FI upgrade acceptances. Default off; receipt
/// recovery/status remain available (`BUZZ_NIP_FI_UPGRADES_ENABLED`).
pub nip_fi_upgrades_enabled: bool,
/// Database resolver bounds (`BUZZ_NIP_FI_RESOLVER_*`,
/// `BUZZ_NIP_FI_SNAPSHOT_*`). Validated in every mode, so a bad value
/// fails the deploy that sets it, not the later switch to shadow.
pub nip_fi_resolver_bounds: crate::nip_fi_resolver::ResolverBounds,
}
fn parse_bind_addr(raw: &str) -> Result<SocketAddr, ConfigError> {
raw.parse::<SocketAddr>()
.map_err(|e| ConfigError::InvalidBindAddr(e.to_string()))
}
fn parse_invite_v1_cutoff(raw: Option<&str>) -> Result<Option<u64>, ConfigError> {
raw.map(|value| {
chrono::DateTime::parse_from_rfc3339(value)
.ok()
.filter(|time| time.offset().local_minus_utc() == 0 && time.timestamp_subsec_nanos() == 0)
.and_then(|time| u64::try_from(time.timestamp()).ok())
.ok_or_else(|| ConfigError::InvalidValue(
"BUZZ_INVITE_V1_INVALID_AFTER must be a UTC RFC3339 timestamp with whole seconds".into(),
))
}).transpose()
}
fn invite_v1_cutoff_from_env() -> Result<Option<u64>, ConfigError> {
match std::env::var("BUZZ_INVITE_V1_INVALID_AFTER") {
Ok(raw) => parse_invite_v1_cutoff(Some(&raw)),
Err(std::env::VarError::NotPresent) => Ok(None),
Err(std::env::VarError::NotUnicode(_)) => Err(ConfigError::InvalidValue(
"BUZZ_INVITE_V1_INVALID_AFTER must be valid Unicode".into(),
)),
}
}
/// An empty value turns the setting off; header names match case-insensitively.
fn parse_client_ip_header(raw: &str) -> Result<Option<axum::http::HeaderName>, ConfigError> {
let raw = raw.trim();
if raw.is_empty() {
return Ok(None);
}
axum::http::HeaderName::from_bytes(raw.to_ascii_lowercase().as_bytes())
.map(Some)
.map_err(|_| {
ConfigError::InvalidValue(format!("BUZZ_CLIENT_IP_HEADER is not a header name: {raw}"))
})
}
fn positive_u64_from_env(name: &str, default: u64) -> Result<u64, ConfigError> {
match std::env::var(name) {
Ok(raw) => raw
.parse::<u64>()
.ok()
.filter(|value| *value > 0)
.ok_or_else(|| ConfigError::InvalidValue(format!("{name} must be a positive integer"))),
Err(std::env::VarError::NotPresent) => Ok(default),
Err(std::env::VarError::NotUnicode(_)) => Err(ConfigError::InvalidValue(format!(
"{name} must be valid Unicode"
))),
}
}
fn rate_limit_config_from_env() -> Result<buzz_auth::RateLimitConfig, ConfigError> {
let defaults = buzz_auth::RateLimitConfig::default();
Ok(buzz_auth::RateLimitConfig {
human_messages_per_min: positive_u64_from_env(
"BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN",
defaults.human_messages_per_min,
)?,
gif_searches_per_min: positive_u64_from_env(
"BUZZ_RATE_LIMIT_GIF_SEARCHES_PER_MIN",
defaults.gif_searches_per_min,
)?,
human_api_calls_per_min: positive_u64_from_env(
"BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN",
defaults.human_api_calls_per_min,
)?,
human_ws_events_per_sec: positive_u64_from_env(
"BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC",
defaults.human_ws_events_per_sec,
)?,
agent_standard_messages_per_min: positive_u64_from_env(
"BUZZ_RATE_LIMIT_AGENT_STANDARD_MESSAGES_PER_MIN",
defaults.agent_standard_messages_per_min,
)?,
agent_standard_api_calls_per_min: positive_u64_from_env(
"BUZZ_RATE_LIMIT_AGENT_STANDARD_API_CALLS_PER_MIN",
defaults.agent_standard_api_calls_per_min,
)?,
agent_elevated_messages_per_min: positive_u64_from_env(
"BUZZ_RATE_LIMIT_AGENT_ELEVATED_MESSAGES_PER_MIN",
defaults.agent_elevated_messages_per_min,
)?,
agent_platform_messages_per_min: positive_u64_from_env(
"BUZZ_RATE_LIMIT_AGENT_PLATFORM_MESSAGES_PER_MIN",
defaults.agent_platform_messages_per_min,
)?,
})
}
fn parse_operator_api_origin(raw: &str) -> Result<String, ConfigError> {
let raw = raw.trim();
let url = url::Url::parse(raw).map_err(|e| {
ConfigError::InvalidValue(format!("RELAY_OPERATOR_API_ORIGIN is not a valid URL: {e}"))
})?;
if !matches!(url.scheme(), "http" | "https")
|| url.host().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.path() != "/"
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err(ConfigError::InvalidValue(
"RELAY_OPERATOR_API_ORIGIN must be an http(s) origin with no credentials, path, query, or fragment"
.to_string(),
));
}
Ok(raw.trim_end_matches('/').to_string())
}
fn parse_push_gateway_delivery_url(raw: &str) -> Result<url::Url, ConfigError> {
let url = url::Url::parse(raw.trim()).map_err(|e| {
ConfigError::InvalidValue(format!(
"BUZZ_PUSH_GATEWAY_DELIVERY_URL is not a valid URL: {e}"
))
})?;
if !matches!(url.scheme(), "http" | "https")
|| url.host().is_none()
|| (url.scheme() == "https" && url.port().is_some())
|| !url.username().is_empty()
|| url.password().is_some()
|| url.path() != "/v1/deliveries/apns"
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err(ConfigError::InvalidValue(
"BUZZ_PUSH_GATEWAY_DELIVERY_URL must be an exact HTTP(S) /v1/deliveries/apns URL without credentials, query, or fragment; explicit ports are supported only for HTTP"
.to_string(),
));
}
Ok(url)
}
fn parse_operator_listener_delivery_urls(
raw: &str,
) -> Result<HashMap<String, url::Url>, ConfigError> {
let mut endpoints = HashMap::new();
let entries: Vec<&str> = raw
.split(';')
.map(str::trim)
.filter(|entry| !entry.is_empty())
.collect();
if !raw.trim().is_empty() && entries.is_empty() {
return Err(ConfigError::InvalidValue(
"BUZZ_OPERATOR_LISTENERS must contain pubkey:delivery_url pairs".to_string(),
));
}
for entry in entries {
let (pubkey, delivery_url) = entry.split_once(':').ok_or_else(|| {
ConfigError::InvalidValue(
"BUZZ_OPERATOR_LISTENERS entries must be pubkey:delivery_url pairs".to_string(),
)
})?;
let pubkey = pubkey.trim().to_ascii_lowercase();
if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(ConfigError::InvalidValue(format!(
"BUZZ_OPERATOR_LISTENERS entry has an invalid pubkey: {pubkey:?}"
)));
}
let url = url::Url::parse(delivery_url.trim()).map_err(|e| {
ConfigError::InvalidValue(format!(
"BUZZ_OPERATOR_LISTENERS endpoint is not a valid URL: {e}"
))
})?;
if !matches!(url.scheme(), "http" | "https")
|| url.host().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err(ConfigError::InvalidValue(
"BUZZ_OPERATOR_LISTENERS endpoints must be HTTP(S) URLs without credentials, query, or fragment"
.to_string(),
));
}
if endpoints.insert(pubkey.clone(), url).is_some() {
return Err(ConfigError::InvalidValue(format!(
"BUZZ_OPERATOR_LISTENERS contains duplicate pubkey: {pubkey}"
)));
}
}
Ok(endpoints)
}
fn parse_bool(name: &str, default: bool) -> Result<bool, ConfigError> {
match std::env::var(name) {
Err(std::env::VarError::NotPresent) => Ok(default),
Err(error) => Err(ConfigError::InvalidValue(format!(
"{name} must be valid UTF-8: {error}"
))),
Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "on" => Ok(true),
"false" | "0" | "off" | "" => Ok(false),
_ => Err(ConfigError::InvalidValue(format!(
"{name} must be true or false"
))),
},
}
}
fn parse_optional_bool(name: &str) -> Result<bool, ConfigError> {
parse_bool(name, false)
}
fn ensure_git_repo_path(
raw: impl Into<std::path::PathBuf>,
) -> Result<std::path::PathBuf, ConfigError> {
ensure_git_path("BUZZ_GIT_REPO_PATH", raw)
}
fn ensure_git_path(
setting: &str,
raw: impl Into<std::path::PathBuf>,
) -> Result<std::path::PathBuf, ConfigError> {
let git_repo_path = raw.into();
if let Err(e) = std::fs::create_dir_all(&git_repo_path) {
return Err(ConfigError::InvalidValue(format!(
"{setting}={} could not be created: {e}",
git_repo_path.display()
)));
}
Ok(git_repo_path)
}
/// Env vars that once gated authenticated media reads.
///
/// `BUZZ_REQUIRE_MEDIA_GET_AUTH` was the real flag; `BUZZ_REQUIRE_MEDIA_READ_AUTH`
/// was documented in `.env.example` as an accepted alias but was never read by
/// the relay. Media reads are now unconditionally authenticated, so both are
/// inert and an operator still setting either — especially to `false` — holds a
/// belief about their deployment that is no longer true.
const INERT_MEDIA_READ_AUTH_VARS: [&str; 2] = [
"BUZZ_REQUIRE_MEDIA_GET_AUTH",
"BUZZ_REQUIRE_MEDIA_READ_AUTH",
];
/// Which of `names` are present, so startup can warn that they do nothing.
///
/// `lookup` is injected rather than calling `std::env::var` directly: process
/// env is global mutable state, so a test that set real vars would race every
/// other test in the binary.
fn inert_env_vars<'a>(names: &[&'a str], lookup: impl Fn(&str) -> Option<String>) -> Vec<&'a str> {
names
.iter()
.copied()
.filter(|name| lookup(name).is_some())
.collect()
}
impl Config {
/// Loads configuration from environment variables, falling back to development defaults.
pub fn from_env() -> Result<Self, ConfigError> {
let bind_addr_raw =
std::env::var("BUZZ_BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:3000".to_string());
let bind_addr = parse_bind_addr(&bind_addr_raw)?;
let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1
let read_database_url = std::env::var("READ_DATABASE_URL")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
// The old seconds-denominated name is a hard startup error, not an
// alias: silently honouring it would mean 1000x the intended budget.
if std::env::var("BUZZ_REPLICA_HEAD_MAX_AGE_SECS").is_ok() {
return Err(ConfigError::InvalidValue(
"BUZZ_REPLICA_HEAD_MAX_AGE_SECS was renamed to BUZZ_REPLICA_READ_MAX_AGE_MS \
(note: milliseconds, not seconds); refusing to start"
.to_string(),
));
}
// Replica read budget: 0 = off (the rollout default), so this is a
// non-negative parse, unlike `positive_u64_from_env`.
let replica_read_max_age_ms = match std::env::var("BUZZ_REPLICA_READ_MAX_AGE_MS") {
Ok(raw) => raw.trim().parse::<u64>().map_err(|_| {
ConfigError::InvalidValue(
"BUZZ_REPLICA_READ_MAX_AGE_MS must be a non-negative integer".to_string(),
)
})?,
Err(_) => 0,
};
let usage_metrics_replica_max_age_ms =
match std::env::var("BUZZ_USAGE_METRICS_REPLICA_MAX_AGE_MS") {
Ok(raw) => raw.trim().parse::<u64>().map_err(|_| {
ConfigError::InvalidValue(
"BUZZ_USAGE_METRICS_REPLICA_MAX_AGE_MS must be a non-negative integer"
.to_string(),
)
})?,
Err(_) => 30_000,
};
// Drain jitter: 0 = off (default). Clamp oversized values so every
// delayed close is initiated with ten seconds left in the relay's
// hard-drain budget. An empty/whitespace-only value is treated as unset
// (jitter off), matching the sibling vars in this file — so setting the
// var to "" is a valid kill switch, not a crashloop.
let drain_jitter_ms = match std::env::var("BUZZ_DRAIN_JITTER_MS") {
Ok(raw) if raw.trim().is_empty() => 0,
Ok(raw) => raw
.trim()
.parse::<u64>()
.map_err(|_| {
ConfigError::InvalidValue(
"BUZZ_DRAIN_JITTER_MS must be a non-negative integer".to_string(),
)
})?
.min(MAX_DRAIN_JITTER_MS),
Err(_) => 0,
};
let redis_url =
std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6379".to_string());
let redis_pool_size = std::env::var("BUZZ_REDIS_POOL_SIZE")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.filter(|&v| v > 0)
.unwrap_or(16);
let db_pool_size = std::env::var("BUZZ_DB_POOL_SIZE")
.ok()
.and_then(|v| v.parse::<u32>().ok())
.filter(|&v| v > 0)
.unwrap_or(50);
let db_read_pool_size = std::env::var("BUZZ_DB_READ_POOL_SIZE")
.ok()
.and_then(|v| v.parse::<u32>().ok())
.filter(|&v| v > 0);
let relay_url =
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string());
let pairing_relay_url = std::env::var("BUZZ_PAIRING_RELAY_URL")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(|value| {
let parsed = url::Url::parse(&value).map_err(|e| {
ConfigError::InvalidValue(format!(
"BUZZ_PAIRING_RELAY_URL must be a valid ws:// or wss:// URL: {e}"
))
})?;
if !matches!(parsed.scheme(), "ws" | "wss") || parsed.host_str().is_none() {
return Err(ConfigError::InvalidValue(
"BUZZ_PAIRING_RELAY_URL must be a valid ws:// or wss:// URL".to_string(),
));
}
Ok(value)
})
.transpose()?;
let max_connections = std::env::var("BUZZ_MAX_CONNECTIONS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10_000);
let max_concurrent_handlers = std::env::var("BUZZ_MAX_CONCURRENT_HANDLERS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1024);
let send_buffer_size = std::env::var("BUZZ_SEND_BUFFER")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1_000);
let max_frame_bytes = std::env::var("BUZZ_MAX_FRAME_BYTES")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.filter(|&v| v > 0)
.unwrap_or(DEFAULT_MAX_FRAME_BYTES);
let slow_client_grace_limit = std::env::var("BUZZ_SLOW_CLIENT_GRACE_LIMIT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(15);
let client_ip_header =
parse_client_ip_header(&std::env::var("BUZZ_CLIENT_IP_HEADER").unwrap_or_default())?;
let require_auth_token = std::env::var("BUZZ_REQUIRE_AUTH_TOKEN")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let pubkey_allowlist_enabled = std::env::var("BUZZ_PUBKEY_ALLOWLIST")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let require_relay_membership = std::env::var("BUZZ_REQUIRE_RELAY_MEMBERSHIP")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
// Defaults true → single-pod (N=1) keeps today's huddle behavior. A
// horizontally-scaled deployment sets this false; see the field doc.
let huddle_audio_available = std::env::var("BUZZ_HUDDLE_AUDIO_AVAILABLE")
.map(|v| !(v == "false" || v == "0"))
.unwrap_or(true);
// Mesh opt-in: default OFF. Strict rollout no-regression — an image
// upgrade with untouched env must not bind a new UDP port or write a
// new Redis key. Horizontally-scaled deployments explicitly set
// `BUZZ_MESH=on`; anything else (absent, `off`, other values) keeps
// exact single-instance behavior.
let mesh_enabled = std::env::var("BUZZ_MESH")
.map(|v| v.eq_ignore_ascii_case("on") || v == "true" || v == "1")
.unwrap_or(false);
let mesh_bind_addr = std::env::var("BUZZ_MESH_BIND_ADDR")
.map(|raw| {
raw.parse::<SocketAddr>().map_err(|e| {
ConfigError::InvalidValue(format!("invalid BUZZ_MESH_BIND_ADDR: {e}"))
})
})
.unwrap_or_else(|_| Ok("0.0.0.0:3478".parse().expect("static default parses")))?;
let mesh = buzz_relay_mesh::MeshConfig {
enabled: mesh_enabled,
bind_addr: mesh_bind_addr,
registry_refresh: std::time::Duration::from_secs(15),
};
// Demo echo opt-in: same strict pattern as BUZZ_MESH — explicit
// `on`/`true`/`1` only, anything else (absent, `off`, typos) is off.
let mesh_demo_echo = std::env::var("BUZZ_MESH_DEMO_ECHO")
.map(|v| v.eq_ignore_ascii_case("on") || v == "true" || v == "1")
.unwrap_or(false);
let allow_nip_oa_auth = std::env::var("BUZZ_ALLOW_NIP_OA_AUTH")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let klipy = std::env::var("BUZZ_KLIPY_API_KEY")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(|api_key| KlipyConfig { api_key });
// Note: intentionally not prefixed with BUZZ_ — this is a relay-identity
// config that may be shared across multiple services (e.g., ACP agent).
let relay_owner_pubkey = std::env::var("RELAY_OWNER_PUBKEY")
.ok()
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.map(|s| {
// Must be exactly 64 lowercase hex characters (32-byte pubkey).
// Fail closed — once RELAY_OWNER_PUBKEY can be the break-glass
// operator root (owner-fallback B), silently discarding a malformed
// value would be a lockout, not a graceful degradation.
let valid = s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit());
if valid {
Ok(s)
} else {
Err(ConfigError::InvalidValue(format!(
"RELAY_OWNER_PUBKEY is not a valid 64-char hex pubkey — \
got: {s:?}. Fix or unset it; a malformed value is a startup error \
because this key can serve as the break-glass operator root when \
RELAY_OPERATOR_PUBKEYS is empty."
)))
}
})
.transpose()?;
// Note: intentionally not prefixed with BUZZ_ — same relay-identity
// config family as RELAY_OWNER_PUBKEY. Comma-separated 64-char hex
// pubkeys. Unlike RELAY_OWNER_PUBKEY (warn-and-ignore), an invalid
// entry here is a hard config error: silently dropping an operator
// pubkey would silently disable provisioning for that operator.
let relay_operator_api_origin = std::env::var("RELAY_OPERATOR_API_ORIGIN")
.ok()
.filter(|raw| !raw.trim().is_empty())
.map(|raw| parse_operator_api_origin(&raw))
.transpose()?;
let relay_operator_pubkeys = match std::env::var("RELAY_OPERATOR_PUBKEYS") {
Ok(raw) => {
let mut pubkeys = Vec::new();
for entry in raw.split(',') {
let entry = entry.trim().to_lowercase();
if entry.is_empty() {
continue;
}
let valid = entry.len() == 64 && entry.chars().all(|c| c.is_ascii_hexdigit());
if !valid {
return Err(ConfigError::InvalidValue(format!(
"RELAY_OPERATOR_PUBKEYS entry is not a valid 64-char hex pubkey: {entry:?}"
)));
}
if !pubkeys.contains(&entry) {
pubkeys.push(entry);
}
}
pubkeys
}
Err(_) => Vec::new(),
};
if !relay_operator_pubkeys.is_empty() && relay_operator_api_origin.is_none() {
// Do NOT fail closed at boot: RELAY_OPERATOR_PUBKEYS is the shared
// allowlist for BOTH the community-provisioning endpoints and the
// NIP-98 admin console. Only provisioning needs the canonical
// origin, so requiring it at boot would force admin-console
// operators to configure a provisioning surface they never use.
// The provisioning endpoints stay fail-closed at request time
// (see `api::operator::authorize_operator_request`, which rejects
// when the origin is unconfigured); this warning names that so an
// operator who *did* want provisioning knows why it 500s.
warn!(
"RELAY_OPERATOR_PUBKEYS is set but RELAY_OPERATOR_API_ORIGIN is not — \
the community-provisioning endpoints (POST /operator/communities) will \
reject every request until RELAY_OPERATOR_API_ORIGIN is set. The NIP-98 \
admin console does not require it and is unaffected."
);
}
let operator_listener_delivery_urls = match std::env::var("BUZZ_OPERATOR_LISTENERS") {
Ok(raw) => parse_operator_listener_delivery_urls(&raw)?,
Err(std::env::VarError::NotPresent) => HashMap::new(),
Err(error) => {
return Err(ConfigError::InvalidValue(format!(
"BUZZ_OPERATOR_LISTENERS must be valid UTF-8: {error}"
)));
}
};
let storage_usage_delivery_url = match std::env::var("BUZZ_STORAGE_USAGE_URL") {
Ok(raw) => Some(
crate::storage_usage_delivery::parse_delivery_url(&raw)
.map_err(ConfigError::InvalidValue)?,
),
Err(std::env::VarError::NotPresent) => None,
Err(error) => {
return Err(ConfigError::InvalidValue(format!(
"BUZZ_STORAGE_USAGE_URL must be valid UTF-8: {error}"
)));
}
};
if !operator_listener_delivery_urls.is_empty() && relay_operator_api_origin.is_none() {
error!(
"BUZZ_OPERATOR_LISTENERS is set but RELAY_OPERATOR_API_ORIGIN is not — operator-listener registration requests will reject every request until RELAY_OPERATOR_API_ORIGIN is set"
);
}
let auth = buzz_auth::AuthConfig {
rate_limits: rate_limit_config_from_env()?,
};
if !require_auth_token {
warn!(
"BUZZ_REQUIRE_AUTH_TOKEN is false — REST API requests bypass token auth. \
WebSocket protocol auth is unaffected. Set to true for production."
);
}
let cors_origins = std::env::var("BUZZ_CORS_ORIGINS")
.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let relay_private_key = std::env::var("BUZZ_RELAY_PRIVATE_KEY").ok();
let uds_path = std::env::var("BUZZ_UDS_PATH")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let health_port = std::env::var("BUZZ_HEALTH_PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(8080);
let metrics_port = std::env::var("BUZZ_METRICS_PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(9102);
// Catalog-only and cheap, but clamp operator mistakes away from a hot
// loop or a multi-day observability gap.
let partition_audit_interval = Duration::from_secs(
std::env::var("BUZZ_PARTITION_AUDIT_INTERVAL_SECS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(15 * 60)
.clamp(60, 24 * 60 * 60),
);
let partition_manager_create_enabled =
parse_bool("BUZZ_PARTITION_MANAGER_CREATE_ENABLED", true)?;
// On by default: a fresh install's migrated catch-all covers months
// from 2026-07, and only advancement replaces it with monthlies.
let partition_manager_advance_enabled =
parse_bool("BUZZ_PARTITION_MANAGER_ADVANCE_ENABLED", true)?;
let s3_addressing_style = match std::env::var("BUZZ_S3_ADDRESSING_STYLE") {
Ok(value) => value.parse().map_err(ConfigError::InvalidValue)?,
Err(std::env::VarError::NotPresent) => buzz_media::config::S3AddressingStyle::default(),
Err(std::env::VarError::NotUnicode(_)) => {
return Err(ConfigError::InvalidValue(
"BUZZ_S3_ADDRESSING_STYLE must be valid Unicode and one of 'path' or 'virtual'"
.to_string(),
));
}
};
let media = buzz_media::MediaConfig {
s3_endpoint: std::env::var("BUZZ_S3_ENDPOINT")
.unwrap_or_else(|_| "http://localhost:9000".to_string()),
s3_access_key: std::env::var("BUZZ_S3_ACCESS_KEY")
.unwrap_or_else(|_| "buzz_dev".to_string()),
s3_secret_key: std::env::var("BUZZ_S3_SECRET_KEY")
.unwrap_or_else(|_| "buzz_dev_secret".to_string()),
s3_bucket: std::env::var("BUZZ_S3_BUCKET").unwrap_or_else(|_| "buzz-media".to_string()),
s3_region: std::env::var("BUZZ_S3_REGION")
.or_else(|_| std::env::var("AWS_REGION"))
.unwrap_or_else(|_| "us-east-1".to_string()),
s3_addressing_style,
max_image_bytes: std::env::var("BUZZ_MAX_IMAGE_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(50 * 1024 * 1024),
max_gif_bytes: std::env::var("BUZZ_MAX_GIF_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10 * 1024 * 1024),
max_video_bytes: std::env::var("BUZZ_MAX_VIDEO_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(500 * 1024 * 1024),
max_file_bytes: std::env::var("BUZZ_MAX_FILE_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(100 * 1024 * 1024),
public_base_url: std::env::var("BUZZ_MEDIA_BASE_URL")
.unwrap_or_else(|_| "http://localhost:3000/media".to_string()),
// Per-upload-event records (`_uploads/` moderation side channel).
// Off by default; coherence between the three knobs is enforced in
// MediaConfig::validate at startup.
upload_records_enabled: std::env::var("BUZZ_MEDIA_UPLOAD_RECORDS")
.map(|v| v == "true" || v == "1")
.unwrap_or(false),
upload_ip_header: std::env::var("BUZZ_MEDIA_UPLOAD_IP_HEADER")
.ok()
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty()),
upload_port_header: std::env::var("BUZZ_MEDIA_UPLOAD_PORT_HEADER")
.ok()
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty()),
};
let media_max_concurrent_uploads: usize =
std::env::var("BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS")
.ok()
.and_then(|v| v.parse().ok())
.filter(|&v| v > 0)
.unwrap_or(8);
let media_max_concurrent_uploads_per_pubkey: u32 =
std::env::var("BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS_PER_PUBKEY")
.ok()
.and_then(|v| v.parse().ok())
.filter(|&v| v > 0)
.unwrap_or(2)
.min(u32::try_from(media_max_concurrent_uploads).unwrap_or(u32::MAX));
let media_uploads_per_minute: u32 = std::env::var("BUZZ_MEDIA_UPLOADS_PER_MINUTE")
.ok()
.and_then(|v| v.parse().ok())
.filter(|&v| v > 0)
.unwrap_or(30);
for name in inert_env_vars(&INERT_MEDIA_READ_AUTH_VARS, |n| std::env::var(n).ok()) {
warn!(
"{name} is set but is no longer read — GET/HEAD /media/* always require \
Blossom t=get auth plus relay membership. Remove it; a value of `false` \
does not re-open unauthenticated media reads."
);
}
let ephemeral_ttl_override = std::env::var("BUZZ_EPHEMERAL_TTL_OVERRIDE")
.ok()
.and_then(|v| v.parse::<i32>().ok())
.filter(|&v| v > 0);
if let Some(ttl) = ephemeral_ttl_override {
warn!(
"BUZZ_EPHEMERAL_TTL_OVERRIDE={ttl}s — all ephemeral channels will use \
this TTL instead of the client-provided value."
);
}
// Git server config
let git_repo_path = ensure_git_repo_path(
std::env::var("BUZZ_GIT_REPO_PATH").unwrap_or_else(|_| "./repos".to_string()),
)?;
let git_pack_cache_path = ensure_git_path(
"BUZZ_GIT_PACK_CACHE_PATH",
std::env::var("BUZZ_GIT_PACK_CACHE_PATH")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| git_repo_path.join(".pack-cache")),
)?;
let git_max_pack_bytes: u64 = std::env::var("BUZZ_GIT_MAX_PACK_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(500 * 1024 * 1024); // 500 MB
let git_max_repo_bytes: u64 = std::env::var("BUZZ_GIT_MAX_REPO_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or_else(|| git_max_pack_bytes.saturating_mul(2)); // 1 GB at defaults
let git_pack_cache_max_bytes: u64 = std::env::var("BUZZ_GIT_PACK_CACHE_MAX_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or_else(|| git_max_repo_bytes.saturating_mul(5)); // 5 GB at defaults
let git_pack_cache_max_concurrent_populations: usize =
std::env::var("BUZZ_GIT_PACK_CACHE_MAX_CONCURRENT_POPULATIONS")
.ok()
.and_then(|v| v.parse().ok())
.filter(|value| *value > 0)
.unwrap_or(2);
let git_max_repos_per_pubkey: u32 = std::env::var("BUZZ_GIT_MAX_REPOS_PER_PUBKEY")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(100);
let git_max_concurrent_ops: usize = std::env::var("BUZZ_GIT_MAX_CONCURRENT_OPS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(20);
let git_hook_hmac_secret: String = std::env::var("BUZZ_GIT_HOOK_HMAC_SECRET")
.unwrap_or_else(|_| {
// Generate a random secret if not configured (dev mode).
let secret: [u8; 32] = rand::random();
hex::encode(secret)
});
let push_enabled = parse_bool("BUZZ_PUSH_ENABLED", false)?;
let push_executor_key_id =
std::env::var("BUZZ_PUSH_EXECUTOR_KEY_ID").unwrap_or_else(|_| "relay-v1".to_string());
if push_executor_key_id.is_empty() || push_executor_key_id.len() > 64 {
return Err(ConfigError::InvalidValue(
"BUZZ_PUSH_EXECUTOR_KEY_ID must contain 1..=64 bytes".to_string(),
));
}
let push_gateway_delivery_url = match std::env::var("BUZZ_PUSH_GATEWAY_DELIVERY_URL") {
Ok(raw) if raw.trim().is_empty() && push_enabled => {
return Err(ConfigError::InvalidValue(
"BUZZ_PUSH_GATEWAY_DELIVERY_URL must not be empty when BUZZ_PUSH_ENABLED=true"
.to_string(),
));
}
Ok(raw) if raw.trim().is_empty() => None,
Ok(raw) => Some(parse_push_gateway_delivery_url(&raw)?),
Err(std::env::VarError::NotPresent) if push_enabled => {
return Err(ConfigError::InvalidValue(
"BUZZ_PUSH_GATEWAY_DELIVERY_URL must be configured when BUZZ_PUSH_ENABLED=true"
.to_string(),
));
}
Err(std::env::VarError::NotPresent) => None,
Err(error) => {
return Err(ConfigError::InvalidValue(format!(
"BUZZ_PUSH_GATEWAY_DELIVERY_URL must be valid UTF-8: {error}"
)));
}
};
let push_gateway_timeout_millis = match std::env::var("BUZZ_PUSH_GATEWAY_TIMEOUT_MS") {
Ok(raw) => raw
.parse::<u64>()
.ok()
.filter(|millis| (100..=10_000).contains(millis))
.ok_or_else(|| {
ConfigError::InvalidValue(
"BUZZ_PUSH_GATEWAY_TIMEOUT_MS must be an integer in 100..=10000"
.to_string(),
)
})?,
Err(_) => 2_000,
};
let push_gateway_timeout = Duration::from_millis(push_gateway_timeout_millis);
let operator_listener_timeout_millis = match std::env::var(
"BUZZ_OPERATOR_LISTENER_TIMEOUT_MS",
) {
Ok(raw) => match raw
.parse::<u64>()
.ok()
.filter(|millis| (100..=10_000).contains(millis))
{
Some(millis) => millis,
None => {
error!(
value = %raw,
"invalid BUZZ_OPERATOR_LISTENER_TIMEOUT_MS; using default 5000ms (expected integer in 100..=10000)"
);
5_000
}
},
Err(std::env::VarError::NotPresent) => 5_000,
Err(err) => {
error!(
error = %err,
"invalid BUZZ_OPERATOR_LISTENER_TIMEOUT_MS; using default 5000ms"
);
5_000
}
};
let operator_listener_timeout = Duration::from_millis(operator_listener_timeout_millis);
const MAX_POLICY_MARKDOWN_BYTES: usize = 256 * 1024;
let read_policy_markdown = |name: &str| -> Result<Option<String>, ConfigError> {
let value = std::env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
if value
.as_ref()
.is_some_and(|value| value.len() > MAX_POLICY_MARKDOWN_BYTES)
{
return Err(ConfigError::InvalidValue(format!(
"{name} must contain at most {MAX_POLICY_MARKDOWN_BYTES} bytes"
)));
}
Ok(value)
};
let terms_markdown = read_policy_markdown("BUZZ_TERMS_OF_SERVICE_MARKDOWN")?;
let privacy_markdown = read_policy_markdown("BUZZ_PRIVACY_POLICY_MARKDOWN")?;
let age_attestation_required = parse_optional_bool("BUZZ_AGE_ATTESTATION_REQUIRED")?;
let audit_enabled = parse_bool("BUZZ_AUDIT_ENABLED", true)?;
let join_policy = if terms_markdown.is_none()
&& privacy_markdown.is_none()
&& !age_attestation_required
{
None
} else {
let mut hasher = Sha256::new();
hasher.update(terms_markdown.as_deref().unwrap_or_default().as_bytes());
hasher.update([0]);
hasher.update(privacy_markdown.as_deref().unwrap_or_default().as_bytes());
hasher.update([0, u8::from(age_attestation_required)]);
Some(JoinPolicyConfig {
terms_markdown,
privacy_markdown,
age_attestation_required,
version: hex::encode(hasher.finalize()),
})
};
// Deployment-admin surface. The route is absent when the host is unset.
let admin = match std::env::var("BUZZ_ADMIN_HOST")
.ok()
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
{
None => {
if std::env::var_os("BUZZ_ADMIN_TOKEN").is_some() {
tracing::warn!(
"BUZZ_ADMIN_TOKEN is set but token authentication was removed — \
the value is ignored; the admin API now supports only \
BUZZ_ADMIN_AUTH=nip98 (default) or disabled; remove \
BUZZ_ADMIN_TOKEN from the environment"
);
}
if std::env::var_os("BUZZ_ADMIN_AUTH").is_some() {
tracing::warn!(
"BUZZ_ADMIN_AUTH is set without BUZZ_ADMIN_HOST — \
the admin dashboard and API stay disabled and the value is ignored"
);
}
None
}
Some(host) => {
if host.contains(['/', '\\', '@']) {
return Err(ConfigError::InvalidValue(
"BUZZ_ADMIN_HOST must be an exact authority".to_string(),
));
}
// IPv6 authorities must be bracketed (RFC 3986). An unbracketed
// literal such as `::1` cannot form a valid URI authority — the
// advertised NIP-11 origin and the NIP-98 `u`-tag verifier would
// emit `http://::1`, which no URL parser accepts, and no real
// client sends an unbracketed IPv6 `Host` header. Reject it here
// so every accepted host yields usable discovery and signing URLs.
if !host.starts_with('[') && host.matches(':').count() > 1 {
return Err(ConfigError::InvalidValue(format!(
"BUZZ_ADMIN_HOST={host} looks like a bare IPv6 literal; \
wrap IPv6 addresses in brackets, e.g. [::1] or [::1]:3000"
)));
}
// Catch-all authority gate: every accepted host is interpolated
// into the NIP-11 advertisement and NIP-98 `u`-tag URLs, so it
// must be exactly an authority — a host with an optional port and
// nothing else. Parsing `http://{host}` and requiring the sentinel
// to carry only a host rejects any shape that smuggles a path,
// query, fragment, or credentials into the value (the bracket guard
// above already names the honest bare-IPv6 shape).
// Structural check, not parse-only: `admin.example.com?x=1` parses
// as a valid URL but lands `?x=1` in the query, which would corrupt
// both the advertised origin and the canonical `u`-tag URL. Mirrors
// `parse_operator_api_origin`. After passing the gate the host is
// lowercased (hostnames are case-insensitive per RFC 4343) so a
// mixed-case BUZZ_ADMIN_HOST round-trips correctly through desktop
// URL parsing, which always lowercases hostnames (the `url` crate
// normalizes an empty path to `/`, so a bare authority satisfies
// `path == "/"`).
let is_bare_authority =
url::Url::parse(&format!("http://{host}")).is_ok_and(|url| {
url.host().is_some()
&& url.username().is_empty()
&& url.password().is_none()
&& url.path() == "/"
&& url.query().is_none()
&& url.fragment().is_none()
});
if !is_bare_authority {
return Err(ConfigError::InvalidValue(format!(
"BUZZ_ADMIN_HOST={host} is not a valid URL authority; \
it must be a host with an optional port and nothing else \
(no path, query, fragment, or credentials), e.g. \
relay.example.com:8443 or [::1]:3000"
)));
}
let host = host.to_lowercase();
// Parse BUZZ_ADMIN_AUTH. Accepted values: "nip98" (default when
// unset or empty) and "disabled". Any other value is a startup
// error (typo-proofing). Token authentication was removed —
// BUZZ_ADMIN_TOKEN in the environment is ignored with a startup
// warning so a deploy that used to honor a credential learns the
// value is now inert without bricking the boot.
if std::env::var_os("BUZZ_ADMIN_TOKEN").is_some() {
tracing::warn!(
"BUZZ_ADMIN_TOKEN is set but token authentication was removed — \
the value is ignored; the admin API now supports only \
BUZZ_ADMIN_AUTH=nip98 (default) or disabled; remove \
BUZZ_ADMIN_TOKEN from the environment"
);
}
let auth = match std::env::var("BUZZ_ADMIN_AUTH")
.ok()
.as_deref()
.map(str::trim)
{
None | Some("") | Some("nip98") => AdminAuth::Nip98,
Some("disabled") => {
tracing::warn!(
"BUZZ_ADMIN_AUTH=disabled — the admin API serves every \
moderation read without authentication to anyone who can \
reach the relay: reports, feedback and attachments, \
restrictions, the community directory, member profiles and \
any stored message by ID, in every community. Writes and \
staffing are refused. Keep the whole relay port private to \
people allowed to see all of that"
);
AdminAuth::Disabled
}
Some(other) => {
return Err(ConfigError::InvalidValue(format!(
"BUZZ_ADMIN_AUTH must be \"nip98\" or \"disabled\"; got \"{other}\""
)))
}
};
let web_dir = std::env::var("BUZZ_ADMIN_WEB_DIR")
.ok()
.map(|value| std::path::PathBuf::from(value.trim()))
.filter(|value| !value.as_os_str().is_empty());
if let Some(ref dir) = web_dir {
if !dir.join("index.html").is_file() {
return Err(ConfigError::InvalidValue(format!(
"BUZZ_ADMIN_WEB_DIR={} does not contain index.html",
dir.display()
)));
}
}
Some(AdminConfig {
host,
auth,
web_dir,
})
}
};
// Web UI static file serving
let web_dir = std::env::var("BUZZ_WEB_DIR")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.map(std::path::PathBuf::from);
let serve_git_web_gui = std::env::var("BUZZ_SERVE_GIT_WEB_GUI")
.map(|value| value == "true" || value == "1")
.unwrap_or(false);
if let Some(ref dir) = web_dir {
if !dir.join("index.html").is_file() {
return Err(ConfigError::InvalidValue(format!(
"BUZZ_WEB_DIR={} does not contain index.html",
dir.display()
)));
}
tracing::info!("BUZZ_WEB_DIR={} — serving web UI from relay", dir.display());
}
// Reject explicitly-configured secrets that are too short.
// The auto-generated fallback is always 64 hex chars (32 bytes), so this
// only fires when someone sets BUZZ_GIT_HOOK_HMAC_SECRET to a weak value.
if std::env::var("BUZZ_GIT_HOOK_HMAC_SECRET").is_ok() && git_hook_hmac_secret.len() < 32 {
return Err(ConfigError::InvalidValue(
"BUZZ_GIT_HOOK_HMAC_SECRET must be at least 32 characters (16 bytes hex)"
.to_string(),
));
}
let buzz_v1_enabled = std::env::var("BUZZ_V1_ENABLED").is_ok_and(|v| v == "true");
// Read only when enabled: a disabled relay ignores every v1 setting.
let buzz_v1_retention_seconds = match std::env::var("BUZZ_V1_RETENTION_SECONDS") {
Ok(v) if buzz_v1_enabled => v.parse().ok().filter(|s| *s > 0).ok_or_else(|| {
ConfigError::InvalidValue("BUZZ_V1_RETENTION_SECONDS must be positive".into())
})?,
_ => buzz_db::personal_read::DEFAULT_RETENTION_SECONDS,
};
let nip_fi = crate::nip_fi_config::NipFiRelayConfig::from_env()?;
let nip_fi_provision_binding = crate::nip_fi_config::parse_provision_binding()?;
let nip_fi_resolver_bounds =
crate::nip_fi_resolver::ResolverBounds::from_env(db_pool_size)?;
if nip_fi.source == crate::nip_fi_config::CommunitySource::Database
&& nip_fi_provision_binding.is_none()
{
return Err(ConfigError::InvalidValue(
"BUZZ_NIP_FI_COMMUNITY_SOURCE=database requires BUZZ_NIP_FI_PROVISION_BINDING \
(the suffix and reserved host)"
.into(),
));
}
Ok(Self {
bind_addr,
database_url,
read_database_url,
replica_read_max_age_ms,
usage_metrics_replica_max_age_ms,
drain_jitter_ms,
redis_url,
redis_pool_size,
db_pool_size,
db_read_pool_size,
relay_url,
pairing_relay_url,
max_connections,
max_concurrent_handlers,
send_buffer_size,
max_frame_bytes,
slow_client_grace_limit,
client_ip_header,
auth,
require_auth_token,
buzz_v1_enabled,
buzz_v1_retention_seconds,
cors_origins,
relay_private_key,
uds_path,
health_port,
metrics_port,
partition_audit_interval,
partition_manager_create_enabled,
partition_manager_advance_enabled,
pubkey_allowlist_enabled,
require_relay_membership,
huddle_audio_available,
mesh,
mesh_demo_echo,
relay_owner_pubkey,
relay_operator_api_origin,
relay_operator_pubkeys,
operator_listener_delivery_urls,
storage_usage_delivery_url,
allow_nip_oa_auth,
klipy,
media,
media_max_concurrent_uploads,
media_max_concurrent_uploads_per_pubkey,
media_uploads_per_minute,
audit_enabled,
ephemeral_ttl_override,
git_repo_path,
git_pack_cache_path,
git_max_pack_bytes,
git_max_repo_bytes,
git_pack_cache_max_bytes,
git_pack_cache_max_concurrent_populations,
git_max_repos_per_pubkey,
git_max_concurrent_ops,
git_hook_hmac_secret,
push_enabled,
push_executor_key_id,
push_gateway_delivery_url,
push_gateway_timeout,
operator_listener_timeout,
join_policy,
invite_v1_invalid_after: invite_v1_cutoff_from_env()?,
admin,
web_dir,
serve_git_web_gui,
nip_fi_provision_binding,
nip_fi_upgrades_enabled: parse_bool("BUZZ_NIP_FI_UPGRADES_ENABLED", false)?,
nip_fi_resolver_bounds,
nip_fi,
})
}
/// Build a baseline `Config` suitable for test fixtures that need a
/// structurally valid config without caring about specific field values.
///
/// Equivalent to `from_env()` with all env variables absent, but calls
/// that function while holding `NIP_FI_ENV_LOCK` so that concurrent NIP-FI
/// env-writer tests cannot produce a partially-written env state that this
/// call observes. Every test fixture that previously called
/// `Config::from_env().expect("…")` should use this instead — it is the
/// only env-isolation-safe way to obtain a default config in test code.
///
/// Fields that differ from production defaults (`database_url`,
/// `redis_url`, etc.) should be overridden on the returned struct after
/// calling this function, exactly as was done before.
///
/// [FI-TRACE-ENV-RACE]
#[cfg(test)]
pub(crate) fn for_test() -> Self {
crate::nip_fi_config::FOR_TEST_LOCK_WAITERS
.lock()
.unwrap()
.push(std::thread::current().id());
let _fi_guard = crate::nip_fi_config::NIP_FI_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Self::from_env().expect("default config must load for test fixture")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn klipy_config_debug_redacts_the_api_key() {
let config = KlipyConfig {
api_key: "private-klipy-key".to_string(),
};
let debug = format!("{config:?}");
assert!(debug.contains("[REDACTED]"));
assert!(!debug.contains("private-klipy-key"));
}
// Mutex to serialize tests that mutate environment variables.
// Parallel env-var mutation causes `defaults_are_valid` to see the invalid
// value set by `invalid_bind_addr_returns_error`, causing a flaky failure.
static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Acquire both the FI env lock and the config-test env lock in a consistent
/// order so concurrent FI writers never observe a partially-written env state.
///
/// Lock ordering: NIP_FI_ENV_LOCK → ENV_MUTEX. Every direct `Config::from_env()`
/// caller in this test module must hold both locks. Use this helper instead of
/// acquiring them separately to guarantee the order is never inverted.
///
/// [FI-TRACE-ENV-RACE]
fn env_guards() -> (
std::sync::MutexGuard<'static, ()>,
std::sync::MutexGuard<'static, ()>,
) {
let fi = crate::nip_fi_config::NIP_FI_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let cfg = ENV_MUTEX.lock().unwrap();
(fi, cfg)
}
#[test]
fn nip_fi_upgrade_acceptance_is_explicitly_default_off() {
let (_fi, _cfg) = env_guards();
let before = std::env::var("BUZZ_NIP_FI_UPGRADES_ENABLED").ok();
std::env::remove_var("BUZZ_NIP_FI_UPGRADES_ENABLED");
assert!(!Config::from_env().unwrap().nip_fi_upgrades_enabled);
for value in ["true", "TRUE", "1", "on", " On "] {
std::env::set_var("BUZZ_NIP_FI_UPGRADES_ENABLED", value);
assert!(
Config::from_env().unwrap().nip_fi_upgrades_enabled,
"{value}"
);
}
for value in ["false", "FALSE", "0", "off", "", " Off "] {
std::env::set_var("BUZZ_NIP_FI_UPGRADES_ENABLED", value);
assert!(
!Config::from_env().unwrap().nip_fi_upgrades_enabled,
"{value}"
);
}
std::env::set_var("BUZZ_NIP_FI_UPGRADES_ENABLED", "treu");
assert!(
matches!(Config::from_env(), Err(ConfigError::InvalidValue(message))
if message.contains("BUZZ_NIP_FI_UPGRADES_ENABLED must be true or false"))
);
match before {
Some(value) => std::env::set_var("BUZZ_NIP_FI_UPGRADES_ENABLED", value),
None => std::env::remove_var("BUZZ_NIP_FI_UPGRADES_ENABLED"),
}
}
// The three `source=database` startup guards. Mutation: dropping any one
// lets that configuration start.
#[test]
fn database_source_config_guards_refuse_startup() {
const VARS: [&str; 6] = [
"BUZZ_NIP_FI_COMMUNITY_SOURCE",
"BUZZ_NIP_FI_COMMUNITIES",
"BUZZ_NIP_FI_ISSUERS",
"BUZZ_NIP_FI_PROVISION_BINDING",
"BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS",
"BUZZ_NIP_FI_MODE",
];
struct Clear;
impl Drop for Clear {
fn drop(&mut self) {
for v in VARS {
std::env::remove_var(v);
}
}
}
let _guards = env_guards();
let _clear = Clear;
let refusal = || match Config::from_env() {
Err(ConfigError::InvalidValue(message)) => message,
other => panic!("expected a refusal, got {:?}", other.map(|_| ())),
};
std::env::set_var("BUZZ_NIP_FI_COMMUNITY_SOURCE", "ldap");
assert!(refusal().contains("must be \"env\" or \"database\""));
std::env::set_var("BUZZ_NIP_FI_COMMUNITY_SOURCE", "database");
std::env::set_var("BUZZ_NIP_FI_COMMUNITIES", "[]");
assert!(refusal().contains("BUZZ_NIP_FI_COMMUNITIES is set"));
std::env::remove_var("BUZZ_NIP_FI_COMMUNITIES");
std::env::set_var(
"BUZZ_NIP_FI_ISSUERS",
serde_json::json!([{
"issuer": "https://idp.example.com",
"command_audiences": ["https://relay.example.com"],
"token_class": "nip-fi+jwt",
"algorithms": ["ES256"],
"maximum_assertion_age_seconds": 3600,
"jwks_uri": "https://idp.example.com/.well-known/jwks.json",
"jwks_refresh_interval_seconds": 300,
"jwks_hard_deadline_seconds": 86400,
"maximum_command_age_seconds": 30,
"authorized_principals": ["https://idp.example.com/operator"]
}])
.to_string(),
);
std::env::set_var("BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", "3600");
assert!(refusal().contains("requires BUZZ_NIP_FI_PROVISION_BINDING"));
std::env::set_var(
"BUZZ_NIP_FI_PROVISION_BINDING",
r#"{"host_suffix":"communities.example.com","authorized_issuers":["https://idp.example.com"]}"#,
);
let config = Config::from_env().expect("complete database-source config");
assert!(config.nip_fi_provision_binding.is_some());
}
// Mutation: parsing the resolver bounds only when the resolver is built
// lets an off-mode deploy carry a value the switch to shadow refuses.
#[test]
fn resolver_bounds_are_validated_in_off_mode() {
let _guards = env_guards();
std::env::set_var("BUZZ_NIP_FI_RESOLVER_MAX_INFLIGHT", "0");
let result = Config::from_env();
std::env::remove_var("BUZZ_NIP_FI_RESOLVER_MAX_INFLIGHT");
assert!(
matches!(result, Err(ref e) if e.to_string().contains("BUZZ_NIP_FI_RESOLVER_MAX_INFLIGHT")),
"{:?}",
result.map(|_| ())
);
}
/// Look up against a fixed set, standing in for process env.
fn env_of<'a>(set: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + use<'a> {
move |name| {
set.iter()
.find(|(key, _)| *key == name)
.map(|(_, value)| (*value).to_string())
}
}
/// The case that matters: an operator who pinned the old flag to `false`
/// must be told it is inert, not left believing media reads are still open.
#[test]
fn inert_media_read_auth_vars_are_reported_even_when_false() {
let found = inert_env_vars(
&INERT_MEDIA_READ_AUTH_VARS,
env_of(&[("BUZZ_REQUIRE_MEDIA_GET_AUTH", "false")]),
);
assert_eq!(found, vec!["BUZZ_REQUIRE_MEDIA_GET_AUTH"]);
}
/// `BUZZ_REQUIRE_MEDIA_READ_AUTH` was advertised in `.env.example` as an
/// accepted alias but the relay never read it, so operators may hold it
/// today. It warns too.
#[test]
fn inert_media_read_auth_vars_include_the_documented_alias() {
let found = inert_env_vars(
&INERT_MEDIA_READ_AUTH_VARS,
env_of(&[
("BUZZ_REQUIRE_MEDIA_GET_AUTH", "true"),
("BUZZ_REQUIRE_MEDIA_READ_AUTH", "false"),
]),
);
assert_eq!(
found,
vec![
"BUZZ_REQUIRE_MEDIA_GET_AUTH",
"BUZZ_REQUIRE_MEDIA_READ_AUTH"
]
);
}
#[test]
fn inert_media_read_auth_vars_stay_quiet_when_unset() {
let found = inert_env_vars(
&INERT_MEDIA_READ_AUTH_VARS,
env_of(&[("BUZZ_REQUIRE_RELAY_MEMBERSHIP", "true")]),
);
assert!(found.is_empty(), "unrelated vars must not warn: {found:?}");
}
#[test]
fn defaults_are_valid() {
let _guards = env_guards();
let config = Config::from_env().expect("default config");
assert!(config.bind_addr.port() > 0);
assert!(!config.database_url.is_empty());
assert!(!config.redis_url.is_empty());
assert_eq!(config.redis_pool_size, 16);
assert_eq!(config.db_pool_size, 50);
assert!(config.max_connections > 0);
assert!(config.send_buffer_size > 0);
assert_eq!(config.max_frame_bytes, DEFAULT_MAX_FRAME_BYTES);
assert_eq!(config.partition_audit_interval, Duration::from_secs(900));
assert!(config.partition_manager_create_enabled);
assert!(config.partition_manager_advance_enabled);
assert!(config.slow_client_grace_limit > 0);
assert!(
!config.pubkey_allowlist_enabled,
"pubkey_allowlist_enabled should default to false"
);
assert!(
!config.require_relay_membership,
"require_relay_membership should default to false"
);
assert!(
config.relay_owner_pubkey.is_none(),
"relay_owner_pubkey should default to None"
);
assert!(
config.relay_operator_pubkeys.is_empty(),
"relay_operator_pubkeys should default empty (provisioning disabled)"
);
assert!(
!config.allow_nip_oa_auth,
"allow_nip_oa_auth should default to false"
);
assert!(
!config.serve_git_web_gui,
"serve_git_web_gui should default to false"
);
assert_eq!(
config.media.s3_addressing_style,
buzz_media::config::S3AddressingStyle::Path,
"S3 addressing must default to path style for bundled MinIO compatibility"
);
assert!(
config.join_policy.is_none(),
"join_policy should default to None so policy prompts and acceptance receipts are opt-in"
);
assert!(
config.huddle_audio_available,
"huddle_audio_available should default to true so single-pod (N=1) keeps today's huddle behavior"
);
}
/// Run `Config::from_env()` with the admin variables forced to `values`,
/// restoring the ambient environment afterwards.
///
/// Callers must already hold [`env_guards()`] before calling this function,
/// so that FI writers cannot produce a partially-written env state while
/// `Config::from_env()` reads it. [FI-TRACE-ENV-RACE]
fn config_with_admin_env(values: &[(&str, Option<&str>)]) -> Result<Config, ConfigError> {
const KEYS: [&str; 3] = ["BUZZ_ADMIN_HOST", "BUZZ_ADMIN_TOKEN", "BUZZ_ADMIN_AUTH"];
let previous: Vec<_> = KEYS
.iter()
.map(|key| (*key, std::env::var_os(key)))
.collect();
for key in KEYS {
std::env::remove_var(key);
}
for (key, value) in values {
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
let config = Config::from_env();
for (key, value) in previous {
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
config
}
/// Like `config_with_admin_env`, but also captures the tracing output
/// emitted during `Config::from_env()` so a test can assert the startup
/// warning fired. The `BUZZ_ADMIN_TOKEN` warning is the sole behavioral
/// value of retaining the guards (the variable is otherwise inert), so it
/// must be regression-protected: deleting a warn block has to fail a test.
fn config_with_admin_env_capturing_logs(
values: &[(&str, Option<&str>)],
) -> (Result<Config, ConfigError>, String) {
use std::sync::{Arc, Mutex};
let _tracing = crate::test_support::tracing_dispatch_lock();
#[derive(Clone)]
struct CapturingMakeWriter {
buf: Arc<Mutex<Vec<u8>>>,
}
struct CapturingWriter {
buf: Arc<Mutex<Vec<u8>>>,
}
impl std::io::Write for CapturingWriter {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
self.buf.lock().unwrap().extend_from_slice(data);
Ok(data.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturingMakeWriter {
type Writer = CapturingWriter;
fn make_writer(&'a self) -> Self::Writer {
CapturingWriter {
buf: Arc::clone(&self.buf),
}
}
}
let buf = Arc::new(Mutex::new(Vec::<u8>::new()));
let subscriber = tracing_subscriber::fmt()
.with_writer(CapturingMakeWriter {
buf: Arc::clone(&buf),
})
.with_ansi(false)
.finish();
let config =
tracing::subscriber::with_default(subscriber, || config_with_admin_env(values));
let captured = String::from_utf8(buf.lock().unwrap().clone()).unwrap_or_default();
(config, captured)
}
fn config_with_operator_listeners(raw: Option<&str>) -> Result<Config, ConfigError> {
let previous = std::env::var_os("BUZZ_OPERATOR_LISTENERS");
match raw {
Some(value) => std::env::set_var("BUZZ_OPERATOR_LISTENERS", value),
None => std::env::remove_var("BUZZ_OPERATOR_LISTENERS"),
}
let result = Config::from_env();
match previous {
Some(value) => std::env::set_var("BUZZ_OPERATOR_LISTENERS", value),
None => std::env::remove_var("BUZZ_OPERATOR_LISTENERS"),
}
result
}
/// Assert `captured` contains a WARN naming the removal of `BUZZ_ADMIN_TOKEN`
/// so the migration breadcrumb Will's ruling preserved cannot silently regress.
fn assert_admin_token_removal_warning(captured: &str) {
assert!(
captured.contains("WARN"),
"expected a WARN line: {captured:?}"
);
for needle in ["BUZZ_ADMIN_TOKEN", "removed", "ignored"] {
assert!(
captured.contains(needle),
"WARN must mention {needle:?}: {captured:?}"
);
}
}
/// A valid-looking token value, used only to prove that setting
/// `BUZZ_ADMIN_TOKEN` is now ignored with a startup warning and never
/// changes the resolved auth mode (token auth was removed).
const SOME_ADMIN_TOKEN: &str =
"5f0e1d2c3b4a59687786958493a2b1c0decadebeefcafe0123456789abcdef01";
#[test]
fn admin_token_set_is_ignored_and_warns_at_startup() {
let _guards = env_guards();
// Token authentication was removed. A lingering BUZZ_ADMIN_TOKEN with a
// host is ignored (logged as a warning) and never changes the resolved
// auth mode: unset/nip98 stay nip98, disabled stays disabled.
for (auth, expected) in [
(None, AdminAuth::Nip98),
(Some("nip98"), AdminAuth::Nip98),
(Some("disabled"), AdminAuth::Disabled),
] {
let (config, logs) = config_with_admin_env_capturing_logs(&[
("BUZZ_ADMIN_HOST", Some("admin.example")),
("BUZZ_ADMIN_TOKEN", Some(SOME_ADMIN_TOKEN)),
("BUZZ_ADMIN_AUTH", auth),
]);
let admin = config
.unwrap_or_else(|e| {
panic!("BUZZ_ADMIN_TOKEN with auth={auth:?} must be ignored: {e:?}")
})
.admin
.expect("admin surface is configured");
assert_eq!(admin.host, "admin.example");
assert_eq!(
std::mem::discriminant(&admin.auth),
std::mem::discriminant(&expected),
"BUZZ_ADMIN_TOKEN must not change auth mode for auth={auth:?}"
);
assert_admin_token_removal_warning(&logs);
}
}
#[test]
fn admin_surface_defaults_to_nip98_when_auth_unset() {
let _guards = env_guards();
let admin = config_with_admin_env(&[("BUZZ_ADMIN_HOST", Some("admin.example"))])
.expect("config with an admin host and no BUZZ_ADMIN_AUTH")
.admin
.expect("admin surface is configured");
assert_eq!(admin.host, "admin.example");
assert!(
matches!(admin.auth, crate::config::AdminAuth::Nip98),
"unset BUZZ_ADMIN_AUTH must default to nip98 (fail-secure)"
);
}
#[test]
fn admin_host_bare_ipv6_literal_fails_closed() {
let _guards = env_guards();
for host in ["::1", "::1:3000", "fe80::1", "2001:db8::1"] {
let result = config_with_admin_env(&[("BUZZ_ADMIN_HOST", Some(host))]);
assert!(
matches!(
result,
Err(ConfigError::InvalidValue(ref message))
if message.contains("BUZZ_ADMIN_HOST") && message.contains("bracket")
),
"bare IPv6 host {host:?} must be rejected: {result:?}"
);
}
}
#[test]
fn admin_host_malformed_authority_fails_closed() {
// Shapes that slip the earlier guards but are not a bare authority, so
// they would corrupt the NIP-11 advertisement and NIP-98 `u`-tag URL:
// - unclosed-bracket typos start with `[` (pass the bracket guard)
// but are not parseable authorities;
// - query/fragment suffixes parse as a valid URL, but the `?x=1` /
// `#frag` lands in the query/fragment rather than the host, so a
// parse-only gate would miss them — the structural check catches them.
let _guards = env_guards();
for host in [
"[::1",
"[::1:3000",
"[not-closed",
"admin.example.com?x=1",
"admin.example.com#frag",
"[::1]?x=1",
"[::1]#frag",
] {
let result = config_with_admin_env(&[("BUZZ_ADMIN_HOST", Some(host))]);
assert!(
matches!(
result,
Err(ConfigError::InvalidValue(ref message))
if message.contains("BUZZ_ADMIN_HOST") && message.contains("valid URL authority")
),
"malformed authority {host:?} must be rejected: {result:?}"
);
}
}
#[test]
fn admin_host_bracketed_ipv6_literal_is_accepted() {
let _guards = env_guards();
for host in ["[::1]", "[::1]:3000", "[2001:db8::1]:8443"] {
let admin = config_with_admin_env(&[("BUZZ_ADMIN_HOST", Some(host))])
.unwrap_or_else(|e| panic!("bracketed IPv6 host {host:?} must be accepted: {e:?}"))
.admin
.expect("admin surface is configured");
assert_eq!(admin.host, host);
}
}
#[test]
fn admin_host_mixed_case_is_normalized_to_lowercase() {
let _guards = env_guards();
// Hostnames are case-insensitive (RFC 4343). A mixed-case BUZZ_ADMIN_HOST
// must be stored lowercase so it round-trips through desktop URL parsing
// (url::Url always lowercases hostnames) without a mismatch.
for (input, expected) in [
("Admin.Example.com", "admin.example.com"),
("Admin.Example.com:8443", "admin.example.com:8443"),
("LOCALHOST:3000", "localhost:3000"),
] {
let admin = config_with_admin_env(&[("BUZZ_ADMIN_HOST", Some(input))])
.unwrap_or_else(|e| panic!("mixed-case host {input:?} must be accepted: {e:?}"))
.admin
.expect("admin surface is configured");
assert_eq!(
admin.host, expected,
"host {input:?} must be stored as lowercase {expected:?}"
);
}
}
#[test]
fn admin_token_without_a_host_is_ignored_and_warns() {
let _guards = env_guards();
// Even without BUZZ_ADMIN_HOST, a lingering BUZZ_ADMIN_TOKEN is ignored
// (logged as a warning) — token auth was removed and the admin surface
// stays absent because the host is unset, not because of the token.
let (config, logs) = config_with_admin_env_capturing_logs(&[
("BUZZ_ADMIN_HOST", None),
("BUZZ_ADMIN_TOKEN", Some(SOME_ADMIN_TOKEN)),
]);
let admin = config
.expect("BUZZ_ADMIN_TOKEN without a host is ignored, not a startup error")
.admin;
assert!(
admin.is_none(),
"admin surface stays absent when the host is unset: {admin:?}"
);
assert_admin_token_removal_warning(&logs);
}
#[test]
fn disabled_mode_activates_without_a_token() {
let _guards = env_guards();
let admin = config_with_admin_env(&[
("BUZZ_ADMIN_HOST", Some("admin.example")),
("BUZZ_ADMIN_TOKEN", None),
("BUZZ_ADMIN_AUTH", Some("disabled")),
])
.expect("disabled mode without a token is valid")
.admin
.expect("admin surface is configured");
assert_eq!(admin.host, "admin.example");
assert!(matches!(admin.auth, crate::config::AdminAuth::Disabled));
}
#[test]
fn admin_auth_junk_values_all_fail_closed() {
let _guards = env_guards();
for junk in [
"1",
"yes",
"TRUE",
"True",
"false",
"0",
"on",
"insecure_no_auth",
// "token" is now a junk value — token authentication was removed.
"token",
] {
let result = config_with_admin_env(&[
("BUZZ_ADMIN_HOST", Some("admin.example")),
("BUZZ_ADMIN_TOKEN", None),
("BUZZ_ADMIN_AUTH", Some(junk)),
]);
assert!(
matches!(
result,
Err(ConfigError::InvalidValue(ref message))
if message.contains("BUZZ_ADMIN_AUTH")
),
"{junk:?} must be rejected: {result:?}"
);
}
}
#[test]
fn admin_auth_empty_string_defaults_to_nip98() {
// An empty value (e.g. `BUZZ_ADMIN_AUTH=`) is treated as unset → nip98,
// the fail-secure default.
let _guards = env_guards();
let admin = config_with_admin_env(&[
("BUZZ_ADMIN_HOST", Some("admin.example")),
("BUZZ_ADMIN_TOKEN", None),
("BUZZ_ADMIN_AUTH", Some("")),
])
.expect("empty BUZZ_ADMIN_AUTH defaults to nip98")
.admin
.expect("admin surface is configured");
assert!(matches!(admin.auth, crate::config::AdminAuth::Nip98));
}
#[test]
fn nip98_mode_parses_and_succeeds_without_pubkeys_env() {
let _guards = env_guards();
let admin = config_with_admin_env(&[
("BUZZ_ADMIN_HOST", Some("admin.example")),
("BUZZ_ADMIN_AUTH", Some("nip98")),
])
.expect(
"nip98 mode succeeds without BUZZ_ADMIN_PUBKEYS (role resolution is at request time)",
)
.admin
.expect("admin surface is configured");
assert!(matches!(admin.auth, crate::config::AdminAuth::Nip98));
}
#[test]
fn buzz_v1_retention_is_validated_only_when_enabled() {
let _guards = env_guards();
const KEYS: [&str; 2] = ["BUZZ_V1_ENABLED", "BUZZ_V1_RETENTION_SECONDS"];
let previous = KEYS.map(std::env::var_os);
std::env::set_var("BUZZ_V1_RETENTION_SECONDS", "0");
std::env::remove_var("BUZZ_V1_ENABLED");
let disabled = Config::from_env();
std::env::set_var("BUZZ_V1_ENABLED", "true");
let enabled = Config::from_env();
for (key, value) in KEYS.into_iter().zip(previous) {
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
assert!(
disabled.is_ok(),
"a disabled relay ignores it: {disabled:?}"
);
assert!(matches!(
enabled,
Err(ConfigError::InvalidValue(ref message))
if message.contains("BUZZ_V1_RETENTION_SECONDS")
));
}
#[test]
fn malformed_relay_owner_pubkey_is_a_startup_error_not_warn_and_ignore() {
let _guards = env_guards();
let previous = std::env::var_os("RELAY_OWNER_PUBKEY");
for bad in ["not-a-pubkey", &"a".repeat(63), &"z".repeat(64), "abcd"] {
std::env::set_var("RELAY_OWNER_PUBKEY", bad);
let result = Config::from_env();
std::env::remove_var("RELAY_OWNER_PUBKEY");
assert!(
matches!(
result,
Err(ConfigError::InvalidValue(ref message))
if message.contains("RELAY_OWNER_PUBKEY")
),
"malformed RELAY_OWNER_PUBKEY {bad:?} must be a startup error, got: {result:?}"
);
}
// Restore.
match previous {
Some(v) => std::env::set_var("RELAY_OWNER_PUBKEY", v),
None => std::env::remove_var("RELAY_OWNER_PUBKEY"),
}
}
#[test]
fn valid_relay_owner_pubkey_parses_correctly() {
let _guards = env_guards();
let previous = std::env::var_os("RELAY_OWNER_PUBKEY");
let valid = "a".repeat(64);
std::env::set_var("RELAY_OWNER_PUBKEY", &valid);
let config = Config::from_env().expect("valid RELAY_OWNER_PUBKEY parses");
std::env::remove_var("RELAY_OWNER_PUBKEY");
if let Some(v) = previous {
std::env::set_var("RELAY_OWNER_PUBKEY", v);
}
assert_eq!(config.relay_owner_pubkey, Some(valid));
}
#[test]
fn partition_manager_config_has_bounded_interval_and_create_kill_switch() {
let _guards = env_guards();
let previous_interval = std::env::var_os("BUZZ_PARTITION_AUDIT_INTERVAL_SECS");
let previous_create = std::env::var_os("BUZZ_PARTITION_MANAGER_CREATE_ENABLED");
std::env::set_var("BUZZ_PARTITION_AUDIT_INTERVAL_SECS", "1");
std::env::set_var("BUZZ_PARTITION_MANAGER_CREATE_ENABLED", "false");
let minimum = Config::from_env().expect("minimum config");
std::env::set_var("BUZZ_PARTITION_AUDIT_INTERVAL_SECS", "999999");
let maximum = Config::from_env().expect("maximum config");
if let Some(value) = previous_interval {
std::env::set_var("BUZZ_PARTITION_AUDIT_INTERVAL_SECS", value);
} else {
std::env::remove_var("BUZZ_PARTITION_AUDIT_INTERVAL_SECS");
}
if let Some(value) = previous_create {
std::env::set_var("BUZZ_PARTITION_MANAGER_CREATE_ENABLED", value);
} else {
std::env::remove_var("BUZZ_PARTITION_MANAGER_CREATE_ENABLED");
}
assert_eq!(minimum.partition_audit_interval, Duration::from_secs(60));
assert!(!minimum.partition_manager_create_enabled);
assert_eq!(
maximum.partition_audit_interval,
Duration::from_secs(24 * 60 * 60)
);
assert!(!maximum.partition_manager_create_enabled);
}
#[test]
fn partition_manager_create_kill_switch_parses_false_values_strictly() {
let _guards = env_guards();
let previous = std::env::var_os("BUZZ_PARTITION_MANAGER_CREATE_ENABLED");
for value in ["FALSE", "off", " false "] {
std::env::set_var("BUZZ_PARTITION_MANAGER_CREATE_ENABLED", value);
let config = Config::from_env().expect("recognized false value");
assert!(!config.partition_manager_create_enabled, "value: {value:?}");
}
std::env::set_var("BUZZ_PARTITION_MANAGER_CREATE_ENABLED", "disable-maybe");
let invalid = Config::from_env();
if let Some(value) = previous {
std::env::set_var("BUZZ_PARTITION_MANAGER_CREATE_ENABLED", value);
} else {
std::env::remove_var("BUZZ_PARTITION_MANAGER_CREATE_ENABLED");
}
assert!(matches!(
invalid,
Err(ConfigError::InvalidValue(ref message))
if message.contains("BUZZ_PARTITION_MANAGER_CREATE_ENABLED")
));
}
#[test]
fn partition_manager_advance_defaults_on_and_parses_strictly() {
let _guards = env_guards();
let previous = std::env::var_os("BUZZ_PARTITION_MANAGER_ADVANCE_ENABLED");
std::env::remove_var("BUZZ_PARTITION_MANAGER_ADVANCE_ENABLED");
let default = Config::from_env().expect("default config");
std::env::set_var("BUZZ_PARTITION_MANAGER_ADVANCE_ENABLED", " TRUE ");
let enabled = Config::from_env().expect("enabled config");
std::env::set_var("BUZZ_PARTITION_MANAGER_ADVANCE_ENABLED", "false");
let disabled = Config::from_env().expect("disabled config");
std::env::set_var("BUZZ_PARTITION_MANAGER_ADVANCE_ENABLED", "enable-maybe");
let invalid = Config::from_env();
if let Some(value) = previous {
std::env::set_var("BUZZ_PARTITION_MANAGER_ADVANCE_ENABLED", value);
} else {
std::env::remove_var("BUZZ_PARTITION_MANAGER_ADVANCE_ENABLED");
}
assert!(default.partition_manager_advance_enabled);
assert!(enabled.partition_manager_advance_enabled);
assert!(!disabled.partition_manager_advance_enabled);
assert!(matches!(
invalid,
Err(ConfigError::InvalidValue(ref message))
if message.contains("BUZZ_PARTITION_MANAGER_ADVANCE_ENABLED")
));
}
#[test]
fn s3_addressing_style_env_accepts_virtual_and_rejects_invalid_values() {
let _guards = env_guards();
let previous = std::env::var_os("BUZZ_S3_ADDRESSING_STYLE");
std::env::set_var("BUZZ_S3_ADDRESSING_STYLE", "virtual");
let configured = Config::from_env()
.expect("virtual style config")
.media
.s3_addressing_style;
std::env::set_var("BUZZ_S3_ADDRESSING_STYLE", "auto");
let invalid = Config::from_env();
if let Some(value) = previous {
std::env::set_var("BUZZ_S3_ADDRESSING_STYLE", value);
} else {
std::env::remove_var("BUZZ_S3_ADDRESSING_STYLE");
}
assert_eq!(configured, buzz_media::config::S3AddressingStyle::Virtual);
assert!(matches!(
invalid,
Err(ConfigError::InvalidValue(ref message))
if message.contains("BUZZ_S3_ADDRESSING_STYLE must be 'path' or 'virtual'")
));
}
#[cfg(unix)]
#[test]
fn s3_addressing_style_env_rejects_non_unicode_values() {
use std::os::unix::ffi::OsStringExt;
let _guards = env_guards();
let previous = std::env::var_os("BUZZ_S3_ADDRESSING_STYLE");
std::env::set_var(
"BUZZ_S3_ADDRESSING_STYLE",
std::ffi::OsString::from_vec(vec![0xff]),
);
let invalid = Config::from_env();
if let Some(value) = previous {
std::env::set_var("BUZZ_S3_ADDRESSING_STYLE", value);
} else {
std::env::remove_var("BUZZ_S3_ADDRESSING_STYLE");
}
assert!(matches!(
invalid,
Err(ConfigError::InvalidValue(ref message))
if message.contains("must be valid Unicode")
));
}
#[test]
fn redis_pool_size_env_override_and_invalid_fallback() {
let _guards = env_guards();
let previous = std::env::var_os("BUZZ_REDIS_POOL_SIZE");
std::env::set_var("BUZZ_REDIS_POOL_SIZE", "32");
let overridden = Config::from_env().expect("config").redis_pool_size;
std::env::set_var("BUZZ_REDIS_POOL_SIZE", "0");
let zero = Config::from_env().expect("config").redis_pool_size;
std::env::set_var("BUZZ_REDIS_POOL_SIZE", "not-a-number");
let junk = Config::from_env().expect("config").redis_pool_size;
if let Some(value) = previous {
std::env::set_var("BUZZ_REDIS_POOL_SIZE", value);
} else {
std::env::remove_var("BUZZ_REDIS_POOL_SIZE");
}
assert_eq!(overridden, 32);
assert_eq!(zero, 16, "zero must fall back to the default");
assert_eq!(junk, 16, "unparsable value must fall back to the default");
}
#[test]
fn db_pool_size_env_override_and_invalid_fallback() {
let _guards = env_guards();
let previous = std::env::var_os("BUZZ_DB_POOL_SIZE");
std::env::set_var("BUZZ_DB_POOL_SIZE", "80");
let overridden = Config::from_env().expect("config").db_pool_size;
std::env::set_var("BUZZ_DB_POOL_SIZE", "0");
let zero = Config::from_env().expect("config").db_pool_size;
std::env::set_var("BUZZ_DB_POOL_SIZE", "not-a-number");
let junk = Config::from_env().expect("config").db_pool_size;
if let Some(value) = previous {
std::env::set_var("BUZZ_DB_POOL_SIZE", value);
} else {
std::env::remove_var("BUZZ_DB_POOL_SIZE");
}
assert_eq!(overridden, 80);
assert_eq!(zero, 50, "zero must fall back to the default");
assert_eq!(junk, 50, "unparsable value must fall back to the default");
}
#[test]
fn db_read_pool_size_env_override_and_invalid_fallback() {
let _guards = env_guards();
let previous = std::env::var_os("BUZZ_DB_READ_POOL_SIZE");
std::env::remove_var("BUZZ_DB_READ_POOL_SIZE");
let unset = Config::from_env().expect("config").db_read_pool_size;
std::env::set_var("BUZZ_DB_READ_POOL_SIZE", "40");
let overridden = Config::from_env().expect("config").db_read_pool_size;
std::env::set_var("BUZZ_DB_READ_POOL_SIZE", "0");
let zero = Config::from_env().expect("config").db_read_pool_size;
std::env::set_var("BUZZ_DB_READ_POOL_SIZE", "not-a-number");
let junk = Config::from_env().expect("config").db_read_pool_size;
if let Some(value) = previous {
std::env::set_var("BUZZ_DB_READ_POOL_SIZE", value);
} else {
std::env::remove_var("BUZZ_DB_READ_POOL_SIZE");
}
assert_eq!(unset, None, "unset must inherit the writer pool sizing");
assert_eq!(overridden, Some(40));
assert_eq!(zero, None, "zero must fall back to inheriting");
assert_eq!(junk, None, "unparsable value must fall back to inheriting");
}
#[test]
fn read_database_url_unset_or_blank_is_none() {
let _guards = env_guards();
let previous = std::env::var_os("READ_DATABASE_URL");
std::env::remove_var("READ_DATABASE_URL");
let unset = Config::from_env().expect("config").read_database_url;
std::env::set_var("READ_DATABASE_URL", " ");
let blank = Config::from_env().expect("config").read_database_url;
std::env::set_var("READ_DATABASE_URL", "postgres://buzz:pw@replica:5432/buzz"); // sadscan:disable np.postgres.1
let set = Config::from_env().expect("config").read_database_url;
if let Some(value) = previous {
std::env::set_var("READ_DATABASE_URL", value);
} else {
std::env::remove_var("READ_DATABASE_URL");
}
assert_eq!(unset, None, "unset READ_DATABASE_URL must disable routing");
assert_eq!(blank, None, "blank READ_DATABASE_URL must disable routing");
assert_eq!(
set.as_deref(),
Some("postgres://buzz:pw@replica:5432/buzz") // sadscan:disable np.postgres.1
);
}
#[test]
fn replica_read_max_age_defaults_off_and_rejects_junk() {
let _guards = env_guards();
let previous = std::env::var_os("BUZZ_REPLICA_READ_MAX_AGE_MS");
let previous_old = std::env::var_os("BUZZ_REPLICA_HEAD_MAX_AGE_SECS");
std::env::remove_var("BUZZ_REPLICA_HEAD_MAX_AGE_SECS");
std::env::remove_var("BUZZ_REPLICA_READ_MAX_AGE_MS");
let unset = Config::from_env().expect("config").replica_read_max_age_ms;
std::env::set_var("BUZZ_REPLICA_READ_MAX_AGE_MS", "1000");
let set = Config::from_env().expect("config").replica_read_max_age_ms;
std::env::set_var("BUZZ_REPLICA_READ_MAX_AGE_MS", "0");
let zero = Config::from_env().expect("config").replica_read_max_age_ms;
std::env::set_var("BUZZ_REPLICA_READ_MAX_AGE_MS", "soon");
let junk = Config::from_env();
// The retired seconds-denominated name must be a hard startup
// error even alongside a valid new-name value: silently ignoring
// it (or honouring it) would mean 1000x the intended budget.
std::env::set_var("BUZZ_REPLICA_READ_MAX_AGE_MS", "1000");
std::env::set_var("BUZZ_REPLICA_HEAD_MAX_AGE_SECS", "5");
let old_name = Config::from_env();
std::env::remove_var("BUZZ_REPLICA_HEAD_MAX_AGE_SECS");
if let Some(value) = previous {
std::env::set_var("BUZZ_REPLICA_READ_MAX_AGE_MS", value);
} else {
std::env::remove_var("BUZZ_REPLICA_READ_MAX_AGE_MS");
}
if let Some(value) = previous_old {
std::env::set_var("BUZZ_REPLICA_HEAD_MAX_AGE_SECS", value);
}
assert_eq!(unset, 0, "replica read routing must default off");
assert_eq!(set, 1000);
assert_eq!(zero, 0, "explicit 0 is off");
assert!(
junk.is_err(),
"an unparsable budget must fail loudly, not silently disable"
);
match old_name {
Err(ConfigError::InvalidValue(message)) => assert!(
message.contains("BUZZ_REPLICA_READ_MAX_AGE_MS"),
"the error must name the replacement env var, got: {message}"
),
other => panic!("old env name must hard-fail startup, got {other:?}"),
}
}
#[test]
fn usage_metrics_replica_budget_defaults_on_independently_of_serving_reads() {
let _guard = ENV_MUTEX.lock().unwrap();
let previous_usage = std::env::var_os("BUZZ_USAGE_METRICS_REPLICA_MAX_AGE_MS");
let previous_serving = std::env::var_os("BUZZ_REPLICA_READ_MAX_AGE_MS");
std::env::remove_var("BUZZ_USAGE_METRICS_REPLICA_MAX_AGE_MS");
std::env::remove_var("BUZZ_REPLICA_READ_MAX_AGE_MS");
let defaults = Config::from_env().expect("config");
std::env::set_var("BUZZ_USAGE_METRICS_REPLICA_MAX_AGE_MS", "0");
let disabled = Config::from_env().expect("config");
if let Some(value) = previous_usage {
std::env::set_var("BUZZ_USAGE_METRICS_REPLICA_MAX_AGE_MS", value);
} else {
std::env::remove_var("BUZZ_USAGE_METRICS_REPLICA_MAX_AGE_MS");
}
if let Some(value) = previous_serving {
std::env::set_var("BUZZ_REPLICA_READ_MAX_AGE_MS", value);
} else {
std::env::remove_var("BUZZ_REPLICA_READ_MAX_AGE_MS");
}
assert_eq!(defaults.replica_read_max_age_ms, 0);
assert_eq!(defaults.usage_metrics_replica_max_age_ms, 30_000);
assert_eq!(disabled.usage_metrics_replica_max_age_ms, 0);
}
#[test]
fn drain_jitter_defaults_off_and_rejects_junk() {
let _guards = env_guards();
let previous = std::env::var_os("BUZZ_DRAIN_JITTER_MS");
std::env::remove_var("BUZZ_DRAIN_JITTER_MS");
let unset = Config::from_env().expect("config").drain_jitter_ms;
std::env::set_var("BUZZ_DRAIN_JITTER_MS", "20000");
let set = Config::from_env().expect("config").drain_jitter_ms;
std::env::set_var("BUZZ_DRAIN_JITTER_MS", "60000");
let capped = Config::from_env().expect("config").drain_jitter_ms;
std::env::set_var("BUZZ_DRAIN_JITTER_MS", "0");
let zero = Config::from_env().expect("config").drain_jitter_ms;
std::env::set_var("BUZZ_DRAIN_JITTER_MS", "soon");
let junk = Config::from_env();
std::env::set_var("BUZZ_DRAIN_JITTER_MS", "");
let empty = Config::from_env()
.expect("empty is a valid kill switch")
.drain_jitter_ms;
std::env::set_var("BUZZ_DRAIN_JITTER_MS", " ");
let blank = Config::from_env()
.expect("whitespace-only is a valid kill switch")
.drain_jitter_ms;
if let Some(value) = previous {
std::env::set_var("BUZZ_DRAIN_JITTER_MS", value);
} else {
std::env::remove_var("BUZZ_DRAIN_JITTER_MS");
}
assert_eq!(unset, 0, "drain jitter must default off");
assert_eq!(set, MAX_DRAIN_JITTER_MS);
assert_eq!(
capped, MAX_DRAIN_JITTER_MS,
"oversized jitter leaves close-frame flush headroom"
);
assert_eq!(zero, 0, "explicit 0 is off");
assert!(
junk.is_err(),
"an unparsable jitter must fail loudly, not silently disable"
);
assert_eq!(
empty, 0,
"an empty value is treated as unset — a kill switch, not a crashloop"
);
assert_eq!(blank, 0, "a whitespace-only value is treated as unset");
}
#[test]
fn audit_logging_defaults_on_and_accepts_explicit_off() {
let _guards = env_guards();
let previous = std::env::var_os("BUZZ_AUDIT_ENABLED");
std::env::remove_var("BUZZ_AUDIT_ENABLED");
assert!(parse_bool("BUZZ_AUDIT_ENABLED", true).unwrap());
std::env::set_var("BUZZ_AUDIT_ENABLED", "false");
assert!(!parse_bool("BUZZ_AUDIT_ENABLED", true).unwrap());
if let Some(value) = previous {
std::env::set_var("BUZZ_AUDIT_ENABLED", value);
} else {
std::env::remove_var("BUZZ_AUDIT_ENABLED");
}
}
#[test]
fn audit_logging_rejects_invalid_boolean() {
let _guards = env_guards();
let previous = std::env::var_os("BUZZ_AUDIT_ENABLED");
std::env::set_var("BUZZ_AUDIT_ENABLED", "sometimes");
let result = parse_bool("BUZZ_AUDIT_ENABLED", true);
if let Some(value) = previous {
std::env::set_var("BUZZ_AUDIT_ENABLED", value);
} else {
std::env::remove_var("BUZZ_AUDIT_ENABLED");
}
assert!(matches!(
result,
Err(ConfigError::InvalidValue(ref message))
if message.contains("BUZZ_AUDIT_ENABLED")
));
}
#[test]
fn join_policy_age_attestation_rejects_invalid_boolean() {
let _guards = env_guards();
let previous = std::env::var_os("BUZZ_AGE_ATTESTATION_REQUIRED");
std::env::set_var("BUZZ_AGE_ATTESTATION_REQUIRED", "sometimes");
let result = parse_optional_bool("BUZZ_AGE_ATTESTATION_REQUIRED");
if let Some(value) = previous {
std::env::set_var("BUZZ_AGE_ATTESTATION_REQUIRED", value);
} else {
std::env::remove_var("BUZZ_AGE_ATTESTATION_REQUIRED");
}
assert!(matches!(
result,
Err(ConfigError::InvalidValue(ref message))
if message.contains("BUZZ_AGE_ATTESTATION_REQUIRED")
));
}
#[test]
fn rate_limits_can_be_overridden() {
let _guards = env_guards();
std::env::set_var("BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN", "1001");
std::env::set_var("BUZZ_RATE_LIMIT_GIF_SEARCHES_PER_MIN", "1004");
std::env::set_var("BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN", "1002");
std::env::set_var("BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC", "1003");
let config = Config::from_env().expect("config");
std::env::remove_var("BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN");
std::env::remove_var("BUZZ_RATE_LIMIT_GIF_SEARCHES_PER_MIN");
std::env::remove_var("BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN");
std::env::remove_var("BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC");
assert_eq!(config.auth.rate_limits.human_messages_per_min, 1001);
assert_eq!(config.auth.rate_limits.gif_searches_per_min, 1004);
assert_eq!(config.auth.rate_limits.human_api_calls_per_min, 1002);
assert_eq!(config.auth.rate_limits.human_ws_events_per_sec, 1003);
}
#[test]
fn rate_limit_overrides_reject_zero() {
let _guards = env_guards();
std::env::set_var("BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC", "0");
let result = Config::from_env();
std::env::remove_var("BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC");
assert!(matches!(
result,
Err(ConfigError::InvalidValue(ref message))
if message.contains("BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC")
));
}
#[test]
fn relay_operator_pubkeys_parse_dedupe_and_normalize() {
let _guards = env_guards();
std::env::set_var(
"RELAY_OPERATOR_PUBKEYS",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb,aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
);
std::env::set_var(
"RELAY_OPERATOR_API_ORIGIN",
"http://buzz.mesh.bb-production.com",
);
let config = Config::from_env().expect("config");
std::env::remove_var("RELAY_OPERATOR_PUBKEYS");
std::env::remove_var("RELAY_OPERATOR_API_ORIGIN");
assert_eq!(
config.relay_operator_pubkeys,
vec![
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
]
);
}
#[test]
fn relay_operator_pubkeys_invalid_entry_is_error() {
let _guards = env_guards();
std::env::set_var("RELAY_OPERATOR_PUBKEYS", "not-a-pubkey");
let result = Config::from_env();
std::env::remove_var("RELAY_OPERATOR_PUBKEYS");
assert!(matches!(
result,
Err(ConfigError::InvalidValue(ref msg)) if msg.contains("RELAY_OPERATOR_PUBKEYS")
));
}
#[test]
fn relay_operator_pubkeys_without_api_origin_boots_and_warns() {
// Regression: RELAY_OPERATOR_PUBKEYS is the shared allowlist for both
// community provisioning and the NIP-98 admin console. Configuring the
// admin console (pubkeys) must NOT force the provisioning origin — boot
// succeeds; provisioning stays fail-closed at request time.
let _guards = env_guards();
std::env::set_var(
"RELAY_OPERATOR_PUBKEYS",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
);
std::env::remove_var("RELAY_OPERATOR_API_ORIGIN");
let result = Config::from_env();
std::env::remove_var("RELAY_OPERATOR_PUBKEYS");
let config = result.expect("pubkeys-set/origin-unset must boot, not fail closed");
assert_eq!(
config.relay_operator_pubkeys,
vec!["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string()]
);
assert!(
config.relay_operator_api_origin.is_none(),
"origin stays unset — only the provisioning path requires it, at request time"
);
}
#[test]
fn relay_operator_api_origin_rejects_paths() {
let _guards = env_guards();
std::env::set_var("RELAY_OPERATOR_API_ORIGIN", "https://buzz.example/operator");
let result = Config::from_env();
std::env::remove_var("RELAY_OPERATOR_API_ORIGIN");
assert!(matches!(
result,
Err(ConfigError::InvalidValue(ref msg)) if msg.contains("must be an http(s) origin")
));
}
#[test]
fn push_is_opt_in_and_gateway_is_required_when_enabled() {
let _guards = env_guards();
let previous_enabled = std::env::var_os("BUZZ_PUSH_ENABLED");
let previous = std::env::var_os("BUZZ_PUSH_GATEWAY_DELIVERY_URL");
std::env::remove_var("BUZZ_PUSH_ENABLED");
std::env::remove_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL");
let config = Config::from_env().expect("default config");
assert!(!config.push_enabled);
assert!(config.push_gateway_delivery_url.is_none());
std::env::set_var("BUZZ_PUSH_ENABLED", "true");
let result = Config::from_env();
assert!(matches!(
result,
Err(ConfigError::InvalidValue(ref message))
if message.contains("must be configured")
));
std::env::set_var(
"BUZZ_PUSH_GATEWAY_DELIVERY_URL",
"https://push.example/v1/deliveries/apns",
);
let config = Config::from_env().expect("enabled push config");
assert!(config.push_enabled);
assert_eq!(
config
.push_gateway_delivery_url
.as_ref()
.map(url::Url::as_str),
Some("https://push.example/v1/deliveries/apns")
);
std::env::set_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL", "");
let result = Config::from_env();
assert!(matches!(
result,
Err(ConfigError::InvalidValue(ref message))
if message.contains("must not be empty")
));
std::env::set_var("BUZZ_PUSH_ENABLED", "false");
let config = Config::from_env().expect("disabled push config");
assert!(config.push_gateway_delivery_url.is_none());
if let Some(value) = previous_enabled {
std::env::set_var("BUZZ_PUSH_ENABLED", value);
} else {
std::env::remove_var("BUZZ_PUSH_ENABLED");
}
if let Some(value) = previous {
std::env::set_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL", value);
} else {
std::env::remove_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL");
}
}
#[test]
fn invalid_push_enabled_value_is_rejected() {
let _guards = env_guards();
let previous = std::env::var_os("BUZZ_PUSH_ENABLED");
std::env::set_var("BUZZ_PUSH_ENABLED", "sometimes");
let result = Config::from_env();
if let Some(value) = previous {
std::env::set_var("BUZZ_PUSH_ENABLED", value);
} else {
std::env::remove_var("BUZZ_PUSH_ENABLED");
}
assert!(matches!(
result,
Err(ConfigError::InvalidValue(ref message))
if message.contains("BUZZ_PUSH_ENABLED")
));
}
#[test]
fn push_gateway_url_is_exact_and_fail_closed() {
assert!(parse_push_gateway_delivery_url("https://push.example/v1/deliveries/apns").is_ok());
let delivery_url = "http://push.example:8080/v1/deliveries/apns";
assert_eq!(
parse_push_gateway_delivery_url(delivery_url)
.unwrap()
.as_str(),
delivery_url
);
assert!(parse_push_gateway_delivery_url("http://push-gateway/v1/deliveries/apns").is_ok());
for invalid in [
"ftp://push.example/v1/deliveries/apns",
"http://push-gateway:8080/v1/deliveries/apns?token=x",
"http://user@push-gateway:8080/v1/deliveries/apns",
"http://push-gateway:8080/v1/deliveries/apns#fragment",
"https://push.example:8443/v1/deliveries/apns",
"https://push.example/v1/deliveries/apns/",
"https://push.example/v1/deliveries/apns?token=x",
"https://user@push.example/v1/deliveries/apns",
] {
assert!(
parse_push_gateway_delivery_url(invalid).is_err(),
"{invalid}"
);
}
}
#[test]
fn operator_listener_routes_parse_and_normalize() {
let routes = parse_operator_listener_delivery_urls(
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:https://one.example/mentions;bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:https://two.example/mentions",
)
.expect("valid operator-listener routes");
assert_eq!(routes.len(), 2);
assert!(
routes.contains_key("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
);
assert!(
routes.contains_key("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
);
}
#[test]
fn operator_listener_routes_reject_unsafe_or_duplicate_entries() {
let key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
for raw in [
format!("{key}:ftp://listener.example/mentions"),
format!("{key}:http://user:pass@listener:8080/mentions"),
format!("{key}:http://listener:8080/mentions?token=secret"),
format!("{key}:http://listener:8080/mentions#fragment"),
format!("{key}:https://user:pass@listener.example/mentions"),
format!("{key}:https://listener.example/mentions?token=secret"),
format!("{key}:https://listener.example/mentions#fragment"),
format!("{key}:http://listener:8080/mentions;{key}:https://other.example"),
format!("{key}:https://listener.example/mentions;{key}:https://other.example"),
] {
assert!(
parse_operator_listener_delivery_urls(&raw).is_err(),
"unsafe or duplicate route accepted: {raw}"
);
}
}
#[test]
fn malformed_operator_listener_config_prevents_startup() {
let _guards = env_guards();
let result = config_with_operator_listeners(Some(";"));
assert!(matches!(
result,
Err(ConfigError::InvalidValue(ref message))
if message.contains("BUZZ_OPERATOR_LISTENERS")
));
}
#[cfg(unix)]
#[test]
fn non_utf8_operator_listener_config_prevents_startup() {
use std::os::unix::ffi::OsStringExt;
let _guards = env_guards();
let previous = std::env::var_os("BUZZ_OPERATOR_LISTENERS");
std::env::set_var(
"BUZZ_OPERATOR_LISTENERS",
std::ffi::OsString::from_vec(vec![0xff]),
);
let result = Config::from_env();
match previous {
Some(value) => std::env::set_var("BUZZ_OPERATOR_LISTENERS", value),
None => std::env::remove_var("BUZZ_OPERATOR_LISTENERS"),
}
assert!(matches!(
result,
Err(ConfigError::InvalidValue(ref message))
if message.contains("BUZZ_OPERATOR_LISTENERS must be valid UTF-8")
));
}
#[test]
fn valid_and_absent_operator_listener_config_preserve_routes() {
let _guards = env_guards();
let key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
for endpoint in [
"https://listener.example/mentions",
"http://operator-listener/mentions",
"http://operator-listener:8080/mentions",
"http://127.0.0.1:8080/mentions",
"http://[::1]:8080/mentions",
] {
let raw = format!("{key}:{endpoint}");
let config = config_with_operator_listeners(Some(&raw)).expect("valid listener config");
assert_eq!(
config
.operator_listener_delivery_urls
.get(key)
.map(url::Url::as_str),
Some(endpoint)
);
}
let config = config_with_operator_listeners(None).expect("absent listener config");
assert!(config.operator_listener_delivery_urls.is_empty());
}
#[test]
fn invalid_push_gateway_timeout_is_not_silently_defaulted() {
let _guards = env_guards();
std::env::set_var("BUZZ_PUSH_GATEWAY_TIMEOUT_MS", "99");
let result = Config::from_env();
std::env::remove_var("BUZZ_PUSH_GATEWAY_TIMEOUT_MS");
assert!(matches!(
result,
Err(ConfigError::InvalidValue(ref message))
if message.contains("BUZZ_PUSH_GATEWAY_TIMEOUT_MS")
));
}
#[test]
fn invalid_push_executor_key_id_is_rejected() {
let _guards = env_guards();
std::env::set_var("BUZZ_PUSH_EXECUTOR_KEY_ID", "");
let result = Config::from_env();
std::env::remove_var("BUZZ_PUSH_EXECUTOR_KEY_ID");
assert!(matches!(
result,
Err(ConfigError::InvalidValue(ref message))
if message.contains("BUZZ_PUSH_EXECUTOR_KEY_ID")
));
}
#[test]
fn huddle_audio_available_can_be_disabled_for_horizontal_scaling() {
let _guards = env_guards();
std::env::set_var("BUZZ_HUDDLE_AUDIO_AVAILABLE", "false");
let config = Config::from_env().expect("config");
std::env::remove_var("BUZZ_HUDDLE_AUDIO_AVAILABLE");
assert!(
!config.huddle_audio_available,
"BUZZ_HUDDLE_AUDIO_AVAILABLE=false must disable huddle audio (multi-pod deployments)"
);
}
#[test]
fn client_ip_header_is_off_when_empty_and_case_insensitive() {
assert_eq!(parse_client_ip_header("").unwrap(), None);
assert_eq!(parse_client_ip_header(" ").unwrap(), None);
assert_eq!(
parse_client_ip_header(" CF-Connecting-IP ").unwrap(),
Some(axum::http::HeaderName::from_static("cf-connecting-ip"))
);
assert!(parse_client_ip_header("not a header").is_err());
}
#[test]
fn invalid_bind_addr_returns_error() {
assert!(matches!(
parse_bind_addr("not-an-addr"),
Err(ConfigError::InvalidBindAddr(_))
));
}
#[test]
fn pairing_relay_url_accepts_websocket_urls_and_rejects_http() {
let _guards = env_guards();
std::env::set_var("BUZZ_PAIRING_RELAY_URL", "wss://pairing.buzz.xyz");
let config = Config::from_env().expect("config");
assert_eq!(
config.pairing_relay_url.as_deref(),
Some("wss://pairing.buzz.xyz")
);
std::env::set_var("BUZZ_PAIRING_RELAY_URL", "https://pairing.buzz.xyz");
let result = Config::from_env();
std::env::remove_var("BUZZ_PAIRING_RELAY_URL");
assert!(matches!(
result,
Err(ConfigError::InvalidValue(ref msg)) if msg.contains("BUZZ_PAIRING_RELAY_URL")
));
}
#[test]
fn max_frame_bytes_can_be_configured() {
let _guards = env_guards();
std::env::set_var("BUZZ_MAX_FRAME_BYTES", "262144");
let config = Config::from_env().expect("config");
std::env::remove_var("BUZZ_MAX_FRAME_BYTES");
assert_eq!(config.max_frame_bytes, 262_144);
}
#[test]
fn git_repo_path_is_created_if_missing() {
let _guards = env_guards();
// Pick a path under temp_dir that definitely doesn't exist yet.
let base = std::env::temp_dir().join(format!(
"buzz-test-git-repo-path-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let nested = base.join("nested").join("repos");
assert!(!nested.exists(), "test precondition: path must not exist");
std::env::set_var("BUZZ_GIT_REPO_PATH", &nested);
let result = Config::from_env();
std::env::remove_var("BUZZ_GIT_REPO_PATH");
let config = result.expect("config should self-bootstrap missing git_repo_path");
assert_eq!(config.git_repo_path, nested);
assert!(
nested.is_dir(),
"git_repo_path should exist after config load"
);
// Cleanup.
let _ = std::fs::remove_dir_all(&base);
}
#[test]
#[cfg(unix)]
fn git_repo_path_unwritable_returns_error() {
// Try to create a path under a regular file — must fail.
// Using /dev/null as the parent guarantees create_dir_all fails on unix.
let bogus = std::path::PathBuf::from("/dev/null/cannot-create-here");
let result = ensure_git_repo_path(&bogus);
assert!(
matches!(result, Err(ConfigError::InvalidValue(ref msg)) if msg.contains("BUZZ_GIT_REPO_PATH")),
"expected InvalidValue mentioning BUZZ_GIT_REPO_PATH, got {result:?}"
);
}
}
#[cfg(test)]
mod invite_cutoff_tests {
use super::*;
#[test]
fn cutoff_configuration_is_optional_and_strict() {
assert_eq!(parse_invite_v1_cutoff(None).unwrap(), None);
assert_eq!(
parse_invite_v1_cutoff(Some("1970-01-01T00:01:40Z")).unwrap(),
Some(100)
);
for raw in [
"",
"tomorrow",
"100",
"2026-10-10T00:00:00+01:00",
"1969-12-31T23:59:59Z",
"2026-10-10T00:00:00.1Z",
] {
assert!(parse_invite_v1_cutoff(Some(raw)).is_err(), "{raw}");
}
}
}