Files
buzz/crates/buzz-relay/src/config.rs
Luke Tornquist 6d07a29137 perf(relay): bound fleet usage metrics collection (#7176)
## Why

The usage poller pays memory and query costs proportional to community
count even when per-community emission is disabled. Every pod loads the
full host map, and the leader materializes all per-community aggregates
before suppressing their labels. That makes telemetry collection itself
a scaling constraint.

## What changed

- Fleet-only usage metrics are now the default. `all` remains available
as an explicit rollback mode.
- In fleet-only mode, the poller skips the community host map and all
community-sized query results.
- Exact community counting is replaced with the PostgreSQL planner's
table-size estimate.
- Stock totals are collected as one fixed row, scanning each stock table
once per hour.
- 1d/7d/30d active-user totals come from one bounded 30-day scan per
day.
- Fleet database snapshots run only on a proved read replica, with a
telemetry-specific freshness budget and server-side statement timeouts.
If no such replica is available, the collection is skipped; it never
falls back to the writer.
- Successful fixed-row snapshots are cached and re-emitted on each poll
tick, so gauges stay scrape-visible without raising query frequency.
- `buzz_usage_snapshot_available{family}` reports whether the latest due
refresh of that family succeeded. After a skipped or failed refresh, the
last values stay visible (with `buzz_usage_snapshot_age_seconds`) but
the family reads unavailable until a 60-second retry succeeds.
- The `all` rollback collector publishes the same availability gauges,
so dashboards gated on availability keep working during a rollback.
- Each due refresh outcome is applied by `apply_stock_refresh` /
`apply_activity_refresh`, the single production mapping from `Ok(Some)`
/ `Ok(None)` / `Err` to cache, availability, skip counter, and retry;
the regression test drives that mapping directly.
- `buzz_usage_snapshot_age_seconds` is emitted in the `all` rollback
too, so age-based alerts keep firing through a rollback.
- Every fleet telemetry attempt that completes lands in
`buzz_db_route_decision`; a query error on a proved reader records
`skipped/replica_error`. An attempt abandoned at the relay deadline
records no route decision and appears only as
`buzz_usage_query_skipped_total{reason="timeout"}`. The shared
`proved_reader` warnings no longer claim "routing to writer", because
the telemetry caller never does (serving callers still record their
writer decision in the route counter).
- The PostgreSQL production-method matrix now exercises
`usage_fleet_stock_snapshot`, so the new `reader/maintenance` pool pair
is proven to be emitted by a real Db method.
- Each fleet collection, reader proof included, runs under a relay-side
`tokio::time::timeout` (10s stock, 20s activity, both above their
server-side `statement_timeout`). A reader that stops answering expires
into the same failure mapping with
`buzz_usage_query_skipped_total{reason="timeout"}`, so it cannot stall
the leader's poller, hold the advisory lock past a liveness check, or
leave a frozen snapshot reporting available.
- Fleet telemetry reader checkouts (`ReaderOperation::Maintenance`) opt
into SQLx `close_on_drop` before their first post-acquire await, so an
abandoned collection's connection is closed within SQLx's 5s
`CLOSE_ON_DROP_TIMEOUT` instead of going through the unbounded
return-to-pool ping while it holds a slot in the reader pool that
serving reads share. Serving reads keep pooled reuse; the cost is one
reader reconnect per fleet refresh.
- On leader demotion, cached snapshots and availability are cleared, and
a fresh collection is forced after the lock is reacquired.
- Fleet-only mode passes no host map to the worker-snapshot storage
emitter. It therefore omits community attribution instead of reporting
every byte as `buzz_storage_unmapped_community_bytes`.
- Lifetime stored-message and active-channel totals are no longer
queried in fleet-only mode. Event throughput remains available through
the existing stored-event counter.
- No test-selector changes: main's `config::tests::` selector in `just
test-unit` and the `run-tests.sh unit` fallback already runs the new
replica-budget config test, and the new fleet schedule, availability,
and storage tests run through the existing bin `tests::` and
`storage_sweep::tests::` selectors.
- Added seeded PostgreSQL contract tests and operator docs covering
rollout, rollback, availability, the no-replica behavior, and leader
election.

No migrations, tables, indexes, or new persistent storage are added.

### What got simpler

The relay-side storage half of the original branch (totals-only S3
listing and `buzz_storage_community_breakdown_available`) was dropped.
Main moved S3 listing out of the relay in #7543 and #7845, so that code
was dead on arrival. The rework adds no storage code path; it only gates
attribution on the presence of a host map.

## Risk

Medium. The default changes which telemetry is emitted:

- In fleet-only mode, `buzz_communities_estimated` replaces the exact
`buzz_communities_total`.
- `buzz_total_messages` and `buzz_total_active_channels` are not
refreshed in fleet-only mode (also called out in the rollout steps of
`docs/usage-metrics.md`).
- Deployments without a verified reader (including local compose and
single-node self-hosts) skip database-derived fleet snapshots instead of
querying the writer, and report both families unavailable.
- Multi-pod dashboards must filter database and storage series to
`buzz_usage_poller_is_leader == 1`.

Setting `BUZZ_USAGE_METRICS_PER_COMMUNITY=all` restores the legacy
collector for temporary rollback.

## Verification

At head `1879ee3aedf4227942120a3b521c8d35fc7839fe` (tree `78477e74`;
merges main `da651ad6`; the PR's diff against main is byte-identical to
`1613823f`'s, minus the Justfile and `run-tests.sh` selector lines that
main's `config::tests::` selector now subsumes):

- On Blox: `cargo fmt --all -- --check` and `cargo clippy -p buzz-db -p
buzz-relay --all-targets -- -D warnings` pass. Relay bin `tests::`
34/34, `config::tests::` 55/55, `storage_sweep::tests::` 10/10 and
`store::usage::tests` pass, as do the four `fleet_` and lock PostgreSQL
tests that use scratch databases, including
`abandoned_fleet_collection_releases_its_reader_slot`.
- Against a freshly migrated PostgreSQL database:
`store::usage::postgres_tests::` 14/14,
`storage_sweep::postgres_tests::` and `buzz-db --test
observability_source` 14/14 pass. The new reader code passes main's
crate-wide typed-acquisition and serving-table policy checks.
- E2E at `1613823f` is reused for this head. It ran in an isolated relay
against a real PostgreSQL primary and streaming standby. In 2/2 runs a
stock read went dark mid-result; the deadline abandoned it at about 10s,
the reader pool's active and size counts dropped to zero about 1s later,
and a fresh activity read and the scheduled stock retry succeeded on new
backends while the dark session stayed open. The `all`-mode and
healthy-replica controls also passed. Main's merged changes are on the
writer path and in startup step timing, and touch none of the reader
checkout, the `close_on_drop` seam, the fleet poller or the deadlines.

At head `1613823fa8ef1a423bc1a499c57bc40d692fcd4f` (tree `12bc6d1b`;
test/docs-only on top of `57749418`), on a Blox workstation:

- `cargo fmt --all -- --check` and `cargo clippy -p buzz-db -p
buzz-relay --all-targets -- -D warnings` pass. Relay bin `tests::` 34/34
pass.
- New PostgreSQL test
`abandoned_fleet_collection_releases_its_reader_slot`: a one-slot reader
pool behind a loopback proxy that goes dark mid-session; the collection
is cut at a deadline, then a later acquire must succeed in under 8s on a
fresh connection. The dark call runs on a fresh `Db` with a cold Aurora
capability cache, so the first post-acquire await is the capability
probe. It passes in 3.4s. With `close_on_drop` removed, or moved below
the probe await, it fails with `PoolTimedOut` after the 15s acquire
timeout. The other three `fleet_` PostgreSQL tests that use scratch
databases pass.
- `each_fleet_family_is_abandoned_at_its_own_deadline` now has an outer
timeout: replacing the stock deadline with a bare `.await` fails it
(`Elapsed`) instead of hanging.

At head `fd194ec79d6c6796d6764564de225ad56485cfc7` (tree `79e94727`,
main merged at `932228936`), on a Blox workstation:

- `cargo fmt --all -- --check` and `cargo clippy -p buzz-relay -p
buzz-db --all-targets -- -D warnings` pass.
- Relay bin `tests::` nextest selection: 27/27 pass (plus
`store::usage::tests`), including
`fleet_refresh_outcomes_drive_availability_through_production_mapping`
and the new `stalled_fleet_collection_times_out_into_failed_refresh`
(paused time: two never-completing collections are abandoned at exactly
10s + 20s; both families read unavailable with cached values kept,
`reason="timeout"` skips, retry scheduled; the next tick runs and
recovers; no route decision is recorded for an abandoned attempt) and
`each_fleet_family_is_abandoned_at_its_own_deadline`. Compile-time
asserts keep each deadline above its `statement_timeout`.
- Falsifiability: replacing the stock deadline with a bare `.await`
makes the new test fail (the tick stalls); swapping the two deadlines
fails the per-family test; deleting `schedule.mark_stock_failure(now)`
from the `Ok(None)` arm fails the mapping test. Trees restored and
re-verified.
- At `970414db`: CI PostgreSQL Tests 809/809 including the
production-method matrix with `reader/maintenance`; Unit Tests ran
`finish_usage_read_records_every_attempt_in_route_decisions`.
- `just ci` / `just test` at `5b18914a` on Blox: not green for
environmental reasons (`mobile-check` could not reach pub.dev;
`_ensure-services` failed so service-backed suites did not run;
`buzz-acp git_bootstrap` fails identically on `origin/main`). PostgreSQL
and integration coverage comes from GitHub CI at this head.

E2E at the previous head `0a01e5f22` (isolated relay, real Postgres
primary plus streaming replica, worker storage snapshot, desktop
frontend in relay mode): with no reader, both families report
skipped/unavailable and route `skipped/disabled` (no writer queries);
fleet-only storage emits totals without `buzz_community_storage_*` or
unmapped bytes; `all` mode reports both availability gauges `1` with
per-community storage series; with a fresh replica, both fleet paths
route `replica/fresh` on `reader/maintenance` acquisitions; desktop
send/receive works. The cached success → due failure → recovery
transition was not reached live (hourly/daily cadence); it is covered by
the seam test above.

The original branch also recorded live verification at `aa3e7023`,
before this rework: reduced-idle cache re-emission, two-relay leader
takeover with demoted availability clearing, the no-reader skip with
60-second retry, `all` rollback restoring per-community series, and a
desktop smoke test. Those runs predate the availability and storage
changes and are not claimed for this head.

## Linear tracking

Related work: BUZZ-167.

---------

Signed-off-by: tornquist <tornquist@squareup.com>
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-10-07 15:46:18 -04:00

2833 lines
117 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::resolve_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 is always read-only. 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`.
/// Always read-only: `authorize()` resolves no principal for this mode, so
/// mutation 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 is always
/// read-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,
/// 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 create uncovered monthly partitions.
pub partition_manager_create_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 HTTPS delivery URLs.
pub operator_listener_delivery_urls: HashMap<String, 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, 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>,
/// 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,
}
fn parse_bind_addr(raw: &str) -> Result<SocketAddr, ConfigError> {
raw.parse::<SocketAddr>()
.map_err(|e| ConfigError::InvalidBindAddr(e.to_string()))
}
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 url.scheme() != "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 HTTPS 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 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}"
)));
}
};
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)?;
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 is \
unauthenticated; the operator has asserted that access is \
controlled at the network layer (reverse proxy, VPN, firewall)"
);
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,
};
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,
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,
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,
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,
admin,
web_dir,
serve_git_web_gui,
nip_fi: crate::nip_fi_config::NipFiRelayConfig::from_env()?,
})
}
/// 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)
}
/// 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.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 _guard = ENV_MUTEX.lock().unwrap();
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 _guard = ENV_MUTEX.lock().unwrap();
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 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}:http://listener.example/mentions"),
format!("{key}:https://user:pass@listener.example/mentions"),
format!("{key}:https://listener.example/mentions?token=secret"),
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";
let raw = format!("{key}:https://listener.example/mentions");
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("https://listener.example/mentions")
);
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 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:?}"
);
}
}