diff --git a/Cargo.lock b/Cargo.lock index 6e8fd552f4..41a068ff88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1270,7 +1270,6 @@ dependencies = [ "http", "launchdarkly-server-sdk", "serde_json", - "tempfile", "thiserror 2.0.18", "tokio", ] @@ -1449,6 +1448,7 @@ dependencies = [ "buzz-datastore-tracing", "buzz-db", "buzz-deletion", + "buzz-feature-flags", "buzz-media", "buzz-pubsub", "buzz-relay-mesh", diff --git a/Justfile b/Justfile index 22e9944c4b..f595fab05b 100644 --- a/Justfile +++ b/Justfile @@ -645,6 +645,10 @@ test-unit: + test(/^api::nip_fi::/)' # Invitation drain/cutoff boundary contracts are infra-free. cargo nextest run -p buzz-relay --lib -E 'test(/^invite_token::/) + test(/^config::invite_cutoff_tests::/)' + # Runtime concurrency caps: the flag controller, provider selection and + # the AUTH shed through handle_auth, whose stub database refuses at + # once. Infra-free. Keep scripts/run-tests.sh in step. + cargo nextest run -p buzz-relay --lib -E 'test(/^concurrency_limits::tests::/) + test(/^feature_flags::tests::/) + test(=handlers::auth::tests::auth_cap_sheds_a_verified_login_with_a_bare_close)' # Relay read caches: the admitted-connection index, the shared-read # gates (open set, restriction, tenant host), the community-active # cache, tenant host cache, the diff --git a/crates/buzz-db/src/concurrency_gate.rs b/crates/buzz-db/src/concurrency_gate.rs new file mode 100644 index 0000000000..ed7ccf9037 --- /dev/null +++ b/crates/buzz-db/src/concurrency_gate.rs @@ -0,0 +1,320 @@ +//! A FIFO concurrency cap whose limit an operator can change at runtime. +//! +//! The gate is created with a ceiling, its startup limit. [`ConcurrencyGate::set_limit`] +//! moves the limit anywhere in `1..=ceiling` without a restart: +//! +//! - Raising it releases permits to waiting callers at once. +//! - Lowering it takes back permits nobody holds at once. Permits still held +//! above the new limit are retired as their holders release them, so no +//! in-flight work is cancelled and no new work is admitted until the count +//! held falls under the new limit. +//! +//! Every limit change, every permit release and every admission settles +//! against the shrink debt under one lock, so a newer limit always supersedes +//! an older one: there is no background resize to race, and a permit tokio +//! returns from a cancelled waiter is retired rather than admitted. + +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Duration; + +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; + +/// A concurrency cap with a runtime-adjustable limit. Clones share one cap. +#[derive(Clone, Debug)] +pub struct ConcurrencyGate { + inner: Arc, +} + +#[derive(Debug)] +struct Inner { + permits: Arc, + ceiling: usize, + state: Mutex, +} + +#[derive(Debug)] +struct LimitState { + /// The limit last set: the permits that exist, held or free, once `debt` + /// is repaid. + limit: usize, + /// Permits held above the current limit. Each release retires one of them + /// instead of returning it to the semaphore. + debt: usize, +} + +impl Inner { + fn state(&self) -> MutexGuard<'_, LimitState> { + // The guarded section never panics, but a poisoned lock must not + // wedge admission for the rest of the process. + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +impl ConcurrencyGate { + /// A gate whose ceiling and startup limit are `ceiling`, raised to at + /// least one and clamped to [`Semaphore::MAX_PERMITS`]. + pub fn new(ceiling: usize) -> Self { + let ceiling = ceiling.clamp(1, Semaphore::MAX_PERMITS); + Self { + inner: Arc::new(Inner { + permits: Arc::new(Semaphore::new(ceiling)), + ceiling, + state: Mutex::new(LimitState { + limit: ceiling, + debt: 0, + }), + }), + } + } + + /// A gate with no practical limit until one is set. + pub fn unbounded() -> Self { + Self::new(Semaphore::MAX_PERMITS) + } + + /// The highest limit this gate accepts: its startup limit. + pub fn ceiling(&self) -> usize { + self.inner.ceiling + } + + /// The current limit. + pub fn limit(&self) -> usize { + self.inner.state().limit + } + + /// Set the limit to `requested`, clamped to `1..=ceiling`, and return the + /// limit applied. + pub fn set_limit(&self, requested: usize) -> usize { + let target = requested.clamp(1, self.inner.ceiling); + let mut state = self.inner.state(); + if target < state.limit { + let excess = state.limit - target; + let retired = self.inner.permits.forget_permits(excess); + state.debt += excess - retired; + } else if target > state.limit { + let added = target - state.limit; + let repaid = added.min(state.debt); + state.debt -= repaid; + self.inner.permits.add_permits(added - repaid); + } + state.limit = target; + target + } + + /// Wait up to `wait` for a permit, in arrival order. `None` means the + /// caller must be shed. + pub async fn admit(&self, wait: Duration) -> Option { + let deadline = tokio::time::Instant::now() + wait; + loop { + let permit = + tokio::time::timeout_at(deadline, Arc::clone(&self.inner.permits).acquire_owned()) + .await + .ok()? + .ok()?; + let mut state = self.inner.state(); + if state.debt == 0 { + drop(state); + return Some(GatePermit { + permit: Some(permit), + inner: Arc::clone(&self.inner), + }); + } + // While there is debt every permit should be held, so this one + // leaked back without a `GatePermit` release: tokio returns a + // permit it had assigned to a waiter that was then cancelled. + // Retire it and keep waiting. + state.debt -= 1; + permit.forget(); + } + } + + /// Permits free to admit right now (tests). + #[cfg(test)] + pub(crate) fn available(&self) -> usize { + self.inner.permits.available_permits() + } +} + +/// One admission through a [`ConcurrencyGate`], released on drop. +#[derive(Debug)] +pub struct GatePermit { + permit: Option, + inner: Arc, +} + +impl Drop for GatePermit { + fn drop(&mut self) { + let Some(permit) = self.permit.take() else { + return; + }; + let mut state = self.inner.state(); + if state.debt > 0 { + state.debt -= 1; + permit.forget(); + } else { + // Release under the lock so a concurrent `set_limit` sees either + // the debt or the returned permit, never neither. + drop(permit); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const WAIT: Duration = Duration::from_secs(1); + + #[test] + fn set_limit_clamps_to_one_through_the_ceiling() { + let gate = ConcurrencyGate::new(4); + assert_eq!(gate.set_limit(0), 1); + assert_eq!(gate.set_limit(9), 4); + assert_eq!(gate.limit(), 4); + assert_eq!(ConcurrencyGate::new(0).ceiling(), 1); + } + + #[tokio::test(start_paused = true)] + async fn a_full_gate_sheds_after_the_wait_and_reopens_on_release() { + let gate = ConcurrencyGate::new(1); + let held = gate.admit(WAIT).await.expect("first caller is admitted"); + let started = tokio::time::Instant::now(); + assert!(gate.admit(WAIT).await.is_none(), "a full gate sheds"); + assert_eq!(started.elapsed(), WAIT); + drop(held); + assert!(gate.admit(WAIT).await.is_some(), "a release admits again"); + } + + /// Each row lowers or raises the limit while `held` permits are out, then + /// releases them all. `admits` is how many more callers get in right after + /// the change; `settled` is the free count once every holder has left. + #[tokio::test(start_paused = true)] + async fn limit_changes_respect_permits_already_held() { + struct Case { + name: &'static str, + held: usize, + limits: &'static [usize], + admits: usize, + settled: usize, + } + let cases = [ + Case { + name: "lower while idle", + held: 0, + limits: &[2], + admits: 2, + settled: 2, + }, + Case { + name: "lower below the count held", + held: 4, + limits: &[2], + admits: 0, + settled: 2, + }, + Case { + name: "lower, then raise before holders leave", + held: 4, + limits: &[1, 3], + admits: 0, + settled: 3, + }, + Case { + name: "lower, then raise past the count held", + held: 3, + limits: &[1, 5], + admits: 2, + settled: 5, + }, + Case { + name: "raise back to the ceiling", + held: 2, + limits: &[2, 6], + admits: 4, + settled: 6, + }, + ]; + for case in cases { + let gate = ConcurrencyGate::new(6); + let mut held = Vec::new(); + for _ in 0..case.held { + held.push(gate.admit(WAIT).await.expect("under the ceiling")); + } + for &limit in case.limits { + gate.set_limit(limit); + } + let mut admitted = Vec::new(); + while let Some(permit) = gate.admit(WAIT).await { + admitted.push(permit); + } + assert_eq!(admitted.len(), case.admits, "{}: admitted", case.name); + drop(admitted); + drop(held); + assert_eq!(gate.available(), case.settled, "{}: settled", case.name); + assert_eq!( + gate.limit(), + case.settled, + "{}: limit matches the permits that exist", + case.name + ); + } + } + + #[tokio::test(start_paused = true)] + async fn raising_the_limit_wakes_a_waiting_caller() { + let gate = ConcurrencyGate::new(2); + gate.set_limit(1); + let _held = gate.admit(WAIT).await.expect("one permit"); + let waiter = tokio::spawn({ + let gate = gate.clone(); + async move { gate.admit(WAIT).await.is_some() } + }); + tokio::task::yield_now().await; + gate.set_limit(2); + assert!(waiter.await.expect("join"), "the raise admits the waiter"); + } + + /// Tokio hands a released permit to the first waiter before that waiter + /// runs. If the waiter is cancelled after a shrink, tokio returns the + /// permit to the semaphore directly, never through `GatePermit::drop`; + /// the next admission must retire it against the debt instead. + #[tokio::test(start_paused = true)] + async fn a_cancelled_waiter_cannot_reopen_a_lowered_gate() { + let gate = ConcurrencyGate::new(2); + let first = gate.admit(WAIT).await.expect("first holder"); + let second = gate.admit(WAIT).await.expect("second holder"); + let waiter = tokio::spawn({ + let gate = gate.clone(); + async move { gate.admit(WAIT).await.is_some() } + }); + tokio::task::yield_now().await; // the waiter queues + drop(first); // tokio assigns this permit to the queued waiter + gate.set_limit(1); // nothing free to forget: one permit of debt + waiter.abort(); // cancelled before it runs again + assert!(waiter.await.expect_err("aborted").is_cancelled()); + + assert!( + gate.admit(WAIT).await.is_none(), + "one holder already fills limit 1" + ); + drop(second); + let admitted = gate.admit(WAIT).await.expect("the holder left"); + assert!(gate.admit(WAIT).await.is_none(), "limit 1 holds"); + drop(admitted); + assert_eq!( + gate.available(), + 1, + "the permits that exist match the limit" + ); + } + + #[test] + fn an_unbounded_gate_can_be_capped() { + let gate = ConcurrencyGate::unbounded(); + assert_eq!(gate.ceiling(), Semaphore::MAX_PERMITS); + assert_eq!(gate.set_limit(3), 3); + assert_eq!(gate.available(), 3); + } +} diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index 56cf748c34..9ce2cb0405 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -42,6 +42,10 @@ pub enum DbError { #[error("access denied: {0}")] AccessDenied(String), + /// Required initial policy cannot be created without issuer bindings. + #[error("required community creation needs NIP-FI issuer bindings")] + NipFiBindingRequired, + /// Invitation admission or minting was denied by authoritative restrictions. #[error("invitation principal is restricted")] InviteRestricted, @@ -176,6 +180,7 @@ impl DbError { E::InvalidData(_) => "invalid_data", E::NotFound(_) | E::ChannelNotFound(_) | E::MemberNotFound(_) => "not_found", E::AccessDenied(_) => "access_denied", + E::NipFiBindingRequired => "nip_fi_binding_required", _ => "other", }; kind.to_owned() diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index d83c5f38c9..b362b9fca5 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -46,6 +46,9 @@ macro_rules! superseded_with_mentions_sql { mod runtime; mod store; +/// Concurrency caps whose limit can change at runtime. +pub mod concurrency_gate; + /// Database error types. pub mod error; @@ -102,6 +105,7 @@ pub use community::{ CreatedCommunityRecord, EnsuredCommunityRecord, OwnedCommunityRecord, UnarchiveCommunityResult, UnarchivedCommunityRecord, }; +pub use concurrency_gate::{ConcurrencyGate, GatePermit}; pub use error::{DbError, Result}; pub use event::{ChannelHeadPrecondition, ChannelHeadWriteStatus, DeletionTarget}; pub use event::{EventQuery, DEFAULT_MAX_PAGE_LIMIT}; diff --git a/crates/buzz-db/src/runtime/history_reads.rs b/crates/buzz-db/src/runtime/history_reads.rs index ec2a59c91d..6942f4d208 100644 --- a/crates/buzz-db/src/runtime/history_reads.rs +++ b/crates/buzz-db/src/runtime/history_reads.rs @@ -17,54 +17,30 @@ //! in that window fall back to the writer as before; history reads on the //! saturated pool are rejected, not moved to the writer. -use std::sync::Arc; use std::time::Duration; -use tokio::sync::{OwnedSemaphorePermit, Semaphore}; +use crate::concurrency_gate::{ConcurrencyGate, GatePermit}; -/// Concurrency cap on history reads holding a reader connection, one per -/// `Db` (shared by its clones, so one per relay process). -#[derive(Clone, Debug)] -pub(crate) struct HistoryReadGate { - permits: Arc, +/// How long a history read waits for a permit before it is rejected. The +/// gate is FIFO, so this bounds queueing delay under a burst; a read that +/// would wait longer is shed and the client retries. +pub(crate) const PERMIT_WAIT: Duration = Duration::from_secs(1); + +/// History transactions allowed at once: the pool less one fifth (at least +/// one) left to authorization and bootstrap reads, and never below one. +/// 25 → 20, 5 → 4, 2 → 1, 1 → 1. This is the gate's ceiling: an operator may +/// lower the limit at runtime but never raise it past the share that keeps +/// readers free for authorization. +pub(crate) fn cap(read_max_connections: u32) -> u32 { + read_max_connections + .saturating_sub((read_max_connections / 5).max(1)) + .max(1) } -impl HistoryReadGate { - /// How long a history read waits for a permit before it is rejected. The - /// semaphore is FIFO, so this bounds queueing delay under a burst; a read - /// that would wait longer is shed and the client retries. - pub(crate) const PERMIT_WAIT: Duration = Duration::from_secs(1); - - /// A gate for a reader pool of `read_max_connections`. - pub(crate) fn for_read_pool(read_max_connections: u32) -> Self { - Self { - permits: Arc::new(Semaphore::new(Self::cap(read_max_connections) as usize)), - } - } - - /// History transactions allowed at once: the pool less one fifth (at - /// least one) left to authorization and bootstrap reads, and never below - /// one. 25 → 20, 5 → 4, 2 → 1, 1 → 1. - pub(crate) fn cap(read_max_connections: u32) -> u32 { - read_max_connections - .saturating_sub((read_max_connections / 5).max(1)) - .max(1) - } - - /// Wait up to [`Self::PERMIT_WAIT`] for a permit. `None` means the read - /// must be rejected. - pub(crate) async fn admit(&self) -> Option { - tokio::time::timeout(Self::PERMIT_WAIT, self.permits.clone().acquire_owned()) - .await - .ok()? - .ok() - } - - /// Permits not currently held (tests). - #[cfg(test)] - pub(crate) fn available(&self) -> usize { - self.permits.available_permits() - } +/// The history-read gate for a reader pool of `read_max_connections`, one per +/// `Db` (shared by its clones, so one per relay process). +pub(crate) fn gate_for_read_pool(read_max_connections: u32) -> ConcurrencyGate { + ConcurrencyGate::new(cap(read_max_connections) as usize) } /// A proved reader transaction plus the history permit it was admitted @@ -75,13 +51,13 @@ impl HistoryReadGate { /// module docs). pub(crate) struct ReaderTx { tx: sqlx::Transaction<'static, sqlx::Postgres>, - _permit: Option, + _permit: Option, } impl ReaderTx { pub(crate) fn new( tx: sqlx::Transaction<'static, sqlx::Postgres>, - permit: Option, + permit: Option, ) -> Self { Self { tx, @@ -106,26 +82,12 @@ impl std::ops::DerefMut for ReaderTx { #[cfg(test)] mod tests { - use super::HistoryReadGate; + use super::cap; #[test] fn cap_leaves_a_fifth_of_the_reader_pool_and_never_reaches_zero() { - for (pool, cap) in [(25, 20), (10, 8), (5, 4), (4, 3), (2, 1), (1, 1), (0, 1)] { - assert_eq!(HistoryReadGate::cap(pool), cap, "pool={pool}"); + for (pool, expected) in [(25, 20), (10, 8), (5, 4), (4, 3), (2, 1), (1, 1), (0, 1)] { + assert_eq!(cap(pool), expected, "pool={pool}"); } } - - #[tokio::test(start_paused = true)] - async fn admit_rejects_after_the_bounded_wait_and_reopens_on_release() { - let gate = HistoryReadGate::for_read_pool(1); - let held = gate.admit().await.expect("first read is admitted"); - let started = tokio::time::Instant::now(); - assert!(gate.admit().await.is_none(), "a full gate rejects"); - assert_eq!(started.elapsed(), HistoryReadGate::PERMIT_WAIT); - drop(held); - assert!( - gate.admit().await.is_some(), - "a released permit admits again" - ); - } } diff --git a/crates/buzz-db/src/runtime/mod.rs b/crates/buzz-db/src/runtime/mod.rs index a8b177a22f..9f78fde1d8 100644 --- a/crates/buzz-db/src/runtime/mod.rs +++ b/crates/buzz-db/src/runtime/mod.rs @@ -306,7 +306,7 @@ pub struct Db { /// Concurrency cap on replica-served subscription-history reads, sized /// from [`Db::read_max_connections`] (see [`history_reads`]). Shared /// across `Db` clones. - pub(crate) history_reads: history_reads::HistoryReadGate, + pub(crate) history_reads: crate::concurrency_gate::ConcurrencyGate, } /// The session that served (or will serve) a routed read, so follow-up @@ -827,7 +827,7 @@ impl Db { fence: std::sync::Arc::new(replica_fence::ReplicaFence::new()), replica_read_max_age, reader_aurora_identity: std::sync::Arc::new(std::sync::OnceLock::new()), - history_reads: history_reads::HistoryReadGate::for_read_pool(read_max_connections), + history_reads: history_reads::gate_for_read_pool(read_max_connections), }) } @@ -1380,9 +1380,7 @@ impl Db { push_enqueue: None, max_connections: pool.options().get_max_connections(), read_max_connections: pool.options().get_max_connections(), - history_reads: history_reads::HistoryReadGate::for_read_pool( - pool.options().get_max_connections(), - ), + history_reads: history_reads::gate_for_read_pool(pool.options().get_max_connections()), pool, read_pool: None, fence: std::sync::Arc::new(replica_fence::ReplicaFence::new()), @@ -1404,7 +1402,7 @@ impl Db { max_connections: pool.options().get_max_connections(), read_max_connections: read_pool.options().get_max_connections(), pool, - history_reads: history_reads::HistoryReadGate::for_read_pool( + history_reads: history_reads::gate_for_read_pool( read_pool.options().get_max_connections(), ), read_pool: Some(read_pool), @@ -1482,6 +1480,13 @@ impl Db { self.read_pool.is_some() } + /// The cap on concurrent subscription-history reads (see + /// [`history_reads`]). Its ceiling is the share of the reader pool that + /// leaves room for authorization reads; operators may lower it at runtime. + pub fn history_read_gate(&self) -> &crate::concurrency_gate::ConcurrencyGate { + &self.history_reads + } + /// Open a reader request transaction and complete the connection-local /// half of the fence proof: `BEGIN ISOLATION LEVEL REPEATABLE READ, READ /// ONLY`, then observe the heartbeat token/epoch as the transaction's @@ -1862,7 +1867,7 @@ impl Db { /// [`history_reads`] permit for as long as their reader transaction, so /// they never fill the reader pool, and they are never moved to the /// writer merely because the reader is busy: a history read that waits - /// out its permit ([`history_reads::HistoryReadGate::PERMIT_WAIT`]) or + /// out its permit ([`history_reads::PERMIT_WAIT`]) or /// its reader acquire ([`Db::READER_ACQUIRE_TIMEOUT`]) on a saturated /// reader pool ([`Db::reader_saturated`]) is rejected with /// [`sqlx::Error::PoolTimedOut`], which the relay answers with its @@ -1922,7 +1927,7 @@ impl Db { } let history = operation == observability::ReaderOperation::SubscriptionHistory; let permit = if history { - let Some(permit) = self.history_reads.admit().await else { + let Some(permit) = self.history_reads.admit(history_reads::PERMIT_WAIT).await else { return Err(Self::reject_history_read(path, "history_read_cap")); }; Some(permit) diff --git a/crates/buzz-db/src/runtime/tests.rs b/crates/buzz-db/src/runtime/tests.rs index 4311f22e4b..231d2feb1e 100644 --- a/crates/buzz-db/src/runtime/tests.rs +++ b/crates/buzz-db/src/runtime/tests.rs @@ -1616,7 +1616,7 @@ async fn history_permit_follows_the_read_session_and_a_full_gate_rejects() { let cap = db.history_reads.available(); assert_eq!( cap, - super::history_reads::HistoryReadGate::cap(db.read_max_connections) as usize + super::history_reads::cap(db.read_max_connections) as usize ); let head = db @@ -1638,7 +1638,12 @@ async fn history_permit_follows_the_read_session_and_a_full_gate_rejects() { // Fill the gate: every remaining permit is held elsewhere. let mut others = Vec::new(); for _ in 1..cap { - others.push(db.history_reads.admit().await.expect("free permit")); + others.push( + db.history_reads + .admit(super::history_reads::PERMIT_WAIT) + .await + .expect("free permit"), + ); } let recorder = DebuggingRecorder::new(); let snapshotter = recorder.snapshotter(); @@ -1655,7 +1660,7 @@ async fn history_permit_follows_the_read_session_and_a_full_gate_rejects() { "the rejection must be the retryable pool timeout; got {rejected:?}" ); assert!( - started.elapsed() >= super::history_reads::HistoryReadGate::PERMIT_WAIT, + started.elapsed() >= super::history_reads::PERMIT_WAIT, "the read must wait out the bounded permit wait before rejecting" ); let decisions: Vec<(String, String)> = snapshotter @@ -1684,13 +1689,37 @@ async fn history_permit_follows_the_read_session_and_a_full_gate_rejects() { drop(session); assert_eq!(db.history_reads.available(), 1); let (_window, session) = db - .get_channel_window_with_session(cid, channel, 10, Some(cursor), None) + .get_channel_window_with_session(cid, channel, 10, Some(cursor.clone()), None) .await .expect("a released permit admits the next read"); assert!(session.is_replica()); drop(session); drop(others); assert_eq!(db.history_reads.available(), cap); + + // An operator lowering the cap at runtime holds new reads to the new + // limit while one is in flight, and raising it readmits them. + let (_window, held) = db + .get_channel_window_with_session(cid, channel, 10, Some(cursor.clone()), None) + .await + .expect("routed cursor window"); + assert_eq!(db.history_read_gate().set_limit(1), 1); + let rejected = db + .get_channel_window_with_session(cid, channel, 10, Some(cursor.clone()), None) + .await + .err() + .expect("a lowered cap must reject the next history read"); + assert!( + matches!(rejected, DbError::Sqlx(sqlx::Error::PoolTimedOut)), + "a lowered cap sheds as retryable; got {rejected:?}" + ); + assert_eq!(db.history_read_gate().set_limit(cap), cap); + let (_window, second) = db + .get_channel_window_with_session(cid, channel, 10, Some(cursor), None) + .await + .expect("a raised cap admits the next read"); + drop((held, second)); + assert_eq!(db.history_reads.available(), cap); } /// Snapshot continuity (Wren, review of 17ea2ff6a): the routed request diff --git a/crates/buzz-db/src/store/community.rs b/crates/buzz-db/src/store/community.rs index b0c35890df..50d1b20f45 100644 --- a/crates/buzz-db/src/store/community.rs +++ b/crates/buzz-db/src/store/community.rs @@ -41,6 +41,10 @@ pub struct CreatedCommunityRecord { pub id: CommunityId, /// Normalized host stored for the community. pub host: String, + /// Whether this transaction inserted the community. + pub created: bool, + /// Stored policy read from the inserted or existing row. + pub requires_nip_fi: bool, } /// Result of atomically creating a community with its initial owner. @@ -611,7 +615,9 @@ impl Db { /// Like [`Db::create_community_with_owner`], binding `nip_fi_issuers` to /// the new community in the same transaction. Identical retries return the - /// original record without touching its bindings. + /// original record without touching its bindings. Operator tenant creation + /// must use [`Db::create_community_with_owner_and_nip_fi_policy`] to apply + /// the configured creation requirement. #[datastore_span( name = "create_community_with_owner_and_nip_fi_issuers", system = "postgresql" @@ -622,6 +628,31 @@ impl Db { owner_pubkey: &str, nip_fi_issuers: &[String], relay_keypair: &nostr::Keys, + ) -> Result> { + self.create_community_with_owner_and_nip_fi_policy( + normalized_host, + owner_pubkey, + nip_fi_issuers, + false, + relay_keypair, + ) + .await + } + + /// Atomically create a community, owner, issuer bindings and initial NIP-FI + /// policy (generation 1 when required, otherwise 0). Identical retries keep + /// the original policy and bindings. Required creation needs issuers. + #[datastore_span( + name = "create_community_with_owner_and_nip_fi_policy", + system = "postgresql" + )] + pub async fn create_community_with_owner_and_nip_fi_policy( + &self, + normalized_host: &str, + owner_pubkey: &str, + nip_fi_issuers: &[String], + requires_nip_fi: bool, + relay_keypair: &nostr::Keys, ) -> Result> { use relay_members::MembershipWrite; let owner_pubkey = owner_pubkey.to_ascii_lowercase(); @@ -644,17 +675,21 @@ impl Db { let row = sqlx::query( r#" - INSERT INTO communities (host) - VALUES ($1) + INSERT INTO communities (host, requires_nip_fi, policy_generation) + VALUES ($1, $2, CASE WHEN $2 THEN 1 ELSE 0 END) ON CONFLICT (lower(host)) DO NOTHING - RETURNING id, host + RETURNING id, host, requires_nip_fi "#, ) .bind(normalized_host) + .bind(requires_nip_fi) .fetch_optional(&mut *tx) .await?; if let Some(row) = row { + if requires_nip_fi && nip_fi_issuers.is_empty() { + return Err(DbError::NipFiBindingRequired); + } let id: Uuid = row.try_get("id")?; let host: String = row.try_get("host")?; @@ -694,12 +729,14 @@ impl Db { let record = CreateCommunityWithOwnerResult::Created(CreatedCommunityRecord { id: CommunityId::from_uuid(id), host, + created: true, + requires_nip_fi: row.try_get("requires_nip_fi")?, }); relay_members::commit_membership_write(tx, record, true, relay_keypair).await } else { let existing = sqlx::query( r#" - SELECT c.id, c.host + SELECT c.id, c.host, c.requires_nip_fi FROM communities c JOIN relay_members rm ON rm.community_id = c.id WHERE lower(c.host) = lower($1) @@ -723,6 +760,8 @@ impl Db { let record = CreateCommunityWithOwnerResult::Created(CreatedCommunityRecord { id: CommunityId::from_uuid(existing.try_get("id")?), host: existing.try_get("host")?, + created: false, + requires_nip_fi: existing.try_get("requires_nip_fi")?, }); tx.commit().await?; Ok(MembershipWrite::unchanged(record)) @@ -1152,7 +1191,10 @@ mod postgres_tests { .expect("same-owner retry"); assert_eq!( retry, - CreateCommunityWithOwnerResult::Created(created.clone()), + CreateCommunityWithOwnerResult::Created(CreatedCommunityRecord { + created: false, + ..created.clone() + }), "retry returns the original row" ); @@ -1629,8 +1671,23 @@ mod postgres_tests { let first = first.expect("first concurrent create"); let second = second.expect("second concurrent create"); - assert!(matches!(first, CreateCommunityWithOwnerResult::Created(_))); - assert_eq!(first, second, "conflict loser re-reads the winning row"); + let ( + CreateCommunityWithOwnerResult::Created(first), + CreateCommunityWithOwnerResult::Created(second), + ) = (first, second) + else { + panic!("both concurrent callers must receive the stored community"); + }; + assert_eq!( + first.id, second.id, + "conflict loser re-reads the winning row" + ); + assert_eq!(first.host, second.host); + assert_eq!(first.requires_nip_fi, second.requires_nip_fi); + assert_ne!( + first.created, second.created, + "exactly one caller inserted the row" + ); } #[tokio::test] diff --git a/crates/buzz-db/src/store/nip_fi_binding.rs b/crates/buzz-db/src/store/nip_fi_binding.rs index ffd25cba96..46dd231f34 100644 --- a/crates/buzz-db/src/store/nip_fi_binding.rs +++ b/crates/buzz-db/src/store/nip_fi_binding.rs @@ -613,6 +613,99 @@ mod postgres_tests { assert!(!community_exists(&db, &bootstrap).await); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn initial_policy_is_atomic_and_replays_preserve_current_state() { + let db = setup_db().await; + let keys = nostr::Keys::generate(); + for required in [false, true] { + let host = unique_host("initial-policy"); + let owner = nostr::Keys::generate().public_key().to_hex(); + let write = db + .create_community_with_owner_and_nip_fi_policy( + &host, + &owner, + &issuers(), + required, + &keys, + ) + .await + .unwrap(); + let CreateCommunityWithOwnerResult::Created(record) = write.outcome else { + panic!("not created") + }; + assert!(record.created); + assert_eq!(record.requires_nip_fi, required); + let before = db.community_auth_policy(record.id).await.unwrap().unwrap(); + assert_eq!(before.requires_nip_fi, required); + assert_eq!(before.policy_generation, i64::from(required)); + assert_eq!(bound(&db, &host).await, Some(issuers())); + let owners = db.list_communities_owned_by(&owner).await.unwrap(); + assert_eq!(owners.communities[0].id, record.id); + // Changed policy and issuer configuration never rewrite a committed row. + let changed = ["https://other.example".to_owned()]; + for bindings in [&[][..], changed.as_slice()] { + let retry = db + .create_community_with_owner_and_nip_fi_policy( + &host, &owner, bindings, !required, &keys, + ) + .await + .unwrap(); + let CreateCommunityWithOwnerResult::Created(retry) = retry.outcome else { + panic!("not replayed") + }; + assert!(!retry.created); + assert_eq!(retry.requires_nip_fi, required); + assert_eq!( + db.community_auth_policy(record.id).await.unwrap().unwrap(), + before + ); + assert_eq!(bound(&db, &host).await, Some(issuers())); + } + // Model an operator disable. A later true replay must report the stored false. + sqlx::query("UPDATE communities SET requires_nip_fi = false, policy_generation = policy_generation + 1 WHERE id = $1") + .bind(record.id.as_uuid()).execute(&db.pool).await.unwrap(); + let disabled = db.community_auth_policy(record.id).await.unwrap().unwrap(); + let replay = db + .create_community_with_owner_and_nip_fi_policy(&host, &owner, &[], true, &keys) + .await + .unwrap(); + let CreateCommunityWithOwnerResult::Created(replay) = replay.outcome else { + panic!("not replayed") + }; + assert!(!replay.created); + assert!(!replay.requires_nip_fi); + assert_eq!( + db.community_auth_policy(record.id).await.unwrap().unwrap(), + disabled + ); + assert_eq!(bound(&db, &host).await, Some(issuers())); + let conflict = db + .create_community_with_owner_and_nip_fi_policy( + &host, + &nostr::Keys::generate().public_key().to_hex(), + &[], + true, + &keys, + ) + .await + .unwrap(); + assert_eq!(conflict.outcome, CreateCommunityWithOwnerResult::HostExists); + } + for bindings in [vec![], vec![String::new()]] { + let host = unique_host("refused-initial-policy"); + let refusal = db + .create_community_with_owner_and_nip_fi_policy(&host, OWNER, &bindings, true, &keys) + .await; + if bindings.is_empty() { + assert!(matches!(refusal, Err(crate::DbError::NipFiBindingRequired))); + } else { + assert!(refusal.is_err()); + } + assert!(!community_exists(&db, &host).await); + } + } + // Deletion takes its exclusive lock, then the community row. A bind that // held the row while waiting for that lock would deadlock with it. // Mutation: locking the row before `guard_transaction` in diff --git a/crates/buzz-feature-flags/Cargo.toml b/crates/buzz-feature-flags/Cargo.toml index 258b54cf6d..a989789fac 100644 --- a/crates/buzz-feature-flags/Cargo.toml +++ b/crates/buzz-feature-flags/Cargo.toml @@ -18,5 +18,4 @@ thiserror = { workspace = true } [dev-dependencies] serde_json = { workspace = true } -tempfile = "3" tokio = { workspace = true } diff --git a/crates/buzz-feature-flags/README.md b/crates/buzz-feature-flags/README.md index 6210701a6e..a9247fe3f9 100644 --- a/crates/buzz-feature-flags/README.md +++ b/crates/buzz-feature-flags/README.md @@ -15,7 +15,8 @@ Provider-neutral types: - `BooleanFlag` - `IntegerFlag` (`i64`, including negative values) -- `EvaluationContext` (required `CommunityId`, optional actor `PublicKey`) +- `EvaluationContext` (a `CommunityId` with an optional actor `PublicKey`, or + `for_process()` for process-wide settings such as concurrency caps) - `FlagEvaluator` (typed `evaluate_bool` and `evaluate_int`) - `StaticEvaluator` (always returns declared defaults) - `EnvironmentEvaluator` (process-wide immutable environment snapshot) @@ -46,164 +47,38 @@ Normalized names must be unique across declared flags. Collisions (for example uniqueness belongs in a future flag registry; this crate does not add a registry yet. -## Relay Artifact Contract (not yet wired into `buzz-relay`) +## Relay Provider Selection -The intended composition root is the outer relay artifact. It must select -exactly one provider at compile time: +`buzz-relay` builds exactly one evaluator at startup, chosen by +`BUZZ_FEATURE_FLAGS_PROVIDER` (outside the `BUZZ_FEATURE_FLAG_` namespace, so +it can never be read as a flag): -- `static-feature-flags` -- `environment-feature-flags` -- `launchdarkly-feature-flags` +| Value | Evaluator | +|-------|-----------| +| unset, `static` | `StaticEvaluator` | +| `environment` | `EnvironmentEvaluator::from_process_environment()` | +| `launchdarkly` | `LaunchDarklyEvaluator`, from `BUZZ_LAUNCHDARKLY_SDK_KEY` and the optional Relay Proxy URL `BUZZ_LAUNCHDARKLY_RELAY_PROXY_URL` | -`launchdarkly-feature-flags` is the only relay feature that forwards to -`buzz-feature-flags/launchdarkly`. `static-feature-flags` and -`environment-feature-flags` keep the reusable crate vendor-neutral and still -allow adapter code to compile in tests. +The value is parsed into one provider; there is no stacking, no precedence and +no fallback from one provider to another, and the evaluator never changes for +the life of the process. Every relay build carries the LaunchDarkly adapter +(`buzz-feature-flags/launchdarkly`), which stays inert unless selected, so one +published artifact serves every deployment and the deployment's configuration +pins its provider. -The relay artifact boundary, not this reusable crate, owns exact-one -enforcement. Consumer integration PRs must carry `exact-one compile_error guards` -verbatim/equivalently. That keeps `buzz-feature-flags` free to compile multiple -adapters for tests while ensuring a staging or production relay artifact cannot -choose a different evaluator at runtime. +The composition root lives in `crates/buzz-relay/src/feature_flags.rs`. It +constructs the evaluator once and hands consumers the shared +`Arc`, keeping the concrete owner to drain environment +diagnostics and to call LaunchDarkly's `close()` at shutdown. -This repository currently adds the crate and adapter, but does **not** yet wire -relay `AppState` or handlers to consume it. `crates/buzz-relay/Cargo.toml` -still has no dependency on `buzz-feature-flags`, so current workspace/default -builds remain intentionally unchanged. The executable contract for the future -relay integration lives in -`tests/fixtures/relay-feature-selection`. - -## Composition Root Shape (artifact boundary) - -Construct once at process startup, then inject shared `Arc` -into server components. - -```rust -use std::sync::Arc; -use std::time::Duration; - -use buzz_feature_flags::{EnvironmentEvaluator, FlagEvaluator, StaticEvaluator}; -#[cfg(feature = "launchdarkly-feature-flags")] -use buzz_feature_flags::launchdarkly::{ - LaunchDarklyEvaluator, LaunchDarklyInitError, LaunchDarklyRuntimeConfig, - LaunchDarklyStartError, -}; - -#[cfg(not(any( - feature = "static-feature-flags", - feature = "environment-feature-flags", - feature = "launchdarkly-feature-flags", -)))] -compile_error!( - "select exactly one relay feature flag provider feature: static-feature-flags, environment-feature-flags, or launchdarkly-feature-flags" -); - -#[cfg(any( - all(feature = "static-feature-flags", feature = "environment-feature-flags"), - all(feature = "static-feature-flags", feature = "launchdarkly-feature-flags"), - all(feature = "environment-feature-flags", feature = "launchdarkly-feature-flags"), -))] -compile_error!( - "select exactly one relay feature flag provider feature: static-feature-flags, environment-feature-flags, or launchdarkly-feature-flags" -); - -#[cfg(any(feature = "static-feature-flags", feature = "environment-feature-flags"))] -#[derive(Default)] -struct FeatureFlagRuntimeConfig; - -#[cfg(feature = "launchdarkly-feature-flags")] -struct FeatureFlagRuntimeConfig { - sdk_key: String, - relay_proxy_endpoint: Option, -} - -struct RelayCompositionRoot { - feature_flags: Arc, - environment_diagnostics: Option>, - #[cfg(feature = "launchdarkly-feature-flags")] - launchdarkly_lifecycle: Option>, -} - -#[cfg(feature = "launchdarkly-feature-flags")] -enum RelayStartupError { - LaunchDarklyInit(LaunchDarklyInitError), - LaunchDarklyStart(LaunchDarklyStartError), -} - -#[cfg(feature = "static-feature-flags")] -fn build_feature_flag_evaluator(_config: FeatureFlagRuntimeConfig) -> RelayCompositionRoot { - RelayCompositionRoot { - feature_flags: Arc::new(StaticEvaluator), - environment_diagnostics: None, - } -} - -#[cfg(feature = "environment-feature-flags")] -fn build_feature_flag_evaluator(config: FeatureFlagRuntimeConfig) -> RelayCompositionRoot { - let _ = config; - let owner = Arc::new(EnvironmentEvaluator::from_process_environment()); - let feature_flags: Arc = owner.clone(); - RelayCompositionRoot { - feature_flags, - environment_diagnostics: Some(owner), - } -} - -#[cfg(feature = "launchdarkly-feature-flags")] -async fn build_feature_flag_evaluator( - config: FeatureFlagRuntimeConfig, -) -> Result { - let mut runtime_config = LaunchDarklyRuntimeConfig::new(config.sdk_key); - if let Some(relay_proxy_endpoint) = config.relay_proxy_endpoint { - runtime_config = runtime_config.with_relay_proxy_endpoint(relay_proxy_endpoint); - } - - let owner = Arc::new( - LaunchDarklyEvaluator::from_runtime_config(runtime_config) - .map_err(RelayStartupError::LaunchDarklyInit)?, - ); - owner - .start_with_default_executor_and_wait(Duration::from_secs(5)) - .await - .map_err(RelayStartupError::LaunchDarklyStart)?; - - let feature_flags: Arc = owner.clone(); - Ok(RelayCompositionRoot { - feature_flags, - environment_diagnostics: None, - launchdarkly_lifecycle: Some(owner), - }) -} -``` - -Each relay feature produces exactly one `build_feature_flag_evaluator` path, and -runtime config carries only the selected provider's settings. There is no -runtime provider enum, no provider-name environment variable, and no fallback -selector. - -Startup is fail-closed: if the selected provider fails initialization (including -`start_with_default_executor_and_wait` for LaunchDarkly), relay startup must -return an error and stop rather than falling back to another evaluator. - -The extra concrete `Arc` is an owner/lifecycle handle to that same evaluator, -not a layered provider. Consumers receive only the cloned `Arc` view. Retain the environment owner to drain diagnostics after -evaluations, and retain the LaunchDarkly owner so shutdown can call `close()`. - -`EnvironmentEvaluator` snapshots environment values once at construction, -retains only `BUZZ_FEATURE_FLAG_` entries, ignores `EvaluationContext`, and -never rereads process environment during evaluation. - -For deterministic tests or embedding, `EnvironmentEvaluator::from_pairs(...)` -constructs the same immutable snapshot from explicit key/value pairs. - -Environment entries are untyped at construction, so malformed present values -are detected when a typed flag is first evaluated. `take_diagnostics()` drains -each sanitized diagnostic at most once per flag key and expected type. The -composition root should retain the concrete environment evaluator and emit each -drained diagnostic as a warning through the application's logging stack. -Diagnostics contain no raw value or environment-variable name; the value -snapshot itself remains immutable. +Startup fails on a local configuration error: an unknown provider, +`launchdarkly` without an SDK key, or a malformed Relay Proxy URL. It does not +fail when LaunchDarkly is unreachable: the relay waits briefly for flag data, +then serves declared defaults while the SDK keeps connecting, and +`buzz_feature_flags_ready{provider}` reports whether the provider has +initialized. Once initialized, LaunchDarkly serves its last-known values +through a later outage, so the gauge stays 1 and flag values do not revert to +defaults until the SDK reconnects and receives new data. ## `buzz-db` Boundary (approved correction) @@ -253,8 +128,9 @@ operation already receives an authoritative authenticated actor for domain behavior. Feature targeting must not add actor/pubkey parameters to otherwise actor-free DB APIs. -For LaunchDarkly, the adapter context always includes kind `community` plus -optional global kind `pubkey`. Percentage rollouts must explicitly choose a +For LaunchDarkly, a community evaluation always includes kind `community` plus +optional global kind `pubkey`; a process evaluation is a single kind `relay` +context keyed `relay`. Percentage rollouts must explicitly choose a kind present in evaluation: rollouts must set `contextKind` to a kind present in evaluation (`community` or `pubkey`) when they are intended to bucket Buzz traffic. LaunchDarkly rollouts where configuration omits `contextKind` defaults @@ -265,7 +141,11 @@ zero and pick the first positive-weight variation. - Evaluation context inputs are authoritative server-resolved values (`community`, optional `actor`), not client-supplied targeting attributes. -- Flags may choose between **equivalent** implementations only. +- Flags may choose between **equivalent** implementations only, with one + exception: an operational limit (a concurrency cap) may be set by a flag + when the flag can only *tighten* it. The consumer clamps the value to a + ceiling fixed at startup, and a missing, non-positive or unavailable value + means the ceiling. Shedding under such a limit must be retryable. - Flags must not weaken authorization, tenant isolation, ordering guarantees, transaction/consistency behavior, or schema invariants. - Declared defaults choose the established safe path. @@ -283,12 +163,13 @@ zero and pick the first positive-weight variation. - LaunchDarkly numbers are SDK `f64` values. Integer evaluation accepts only mathematically integral values that convert to `i64` exactly from that `f64` representation; otherwise it returns each flag's declared default. -- Startup chooses one evaluator at compile time (`StaticEvaluator`, +- Startup chooses one evaluator from configuration (`StaticEvaluator`, `EnvironmentEvaluator`, or LaunchDarkly); provider precedence/stacking is out of scope. - If LaunchDarkly is used, call evaluator `close()` during process shutdown. - `close()` blocks the calling thread while it flushes analytics, so async - shutdown code should offload it to a blocking shutdown path. + `close()` blocks the calling thread while it flushes analytics and has no + deadline of its own, so async shutdown code should run it off the runtime + and bound the wait (the relay gives it a detached thread and 2 s). - Safety invariants must never rely on remote-flag availability. ## Build and Test Expectations @@ -319,16 +200,6 @@ cargo tree -p buzz-feature-flags --features launchdarkly Testing expectations: - Run provider-neutral tests and LaunchDarkly-feature tests for this crate. -- Run the relay-artifact compile contract in - `tests/relay_feature_selection_contract.rs`, including zero-feature and - multiple-feature rejection. -- Keep `tests/fixtures/relay-feature-selection/Cargo.lock` checked in for the - nested `--locked` compile contract. Regenerate it from repo root with: - -```bash -cargo generate-lockfile --manifest-path crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.toml -``` - - When `buzz-db` adopts flag-gated query selection, add parity tests proving old/new query paths return equivalent rows, ordering, and transactional behavior for the same inputs. diff --git a/crates/buzz-feature-flags/src/launchdarkly.rs b/crates/buzz-feature-flags/src/launchdarkly.rs index f5ce4a33c8..b966ab9632 100644 --- a/crates/buzz-feature-flags/src/launchdarkly.rs +++ b/crates/buzz-feature-flags/src/launchdarkly.rs @@ -10,6 +10,9 @@ use crate::{BooleanFlag, EvaluationContext, FlagEvaluator, IntegerFlag}; const COMMUNITY_CONTEXT_KIND: &str = "community"; const PUBKEY_CONTEXT_KIND: &str = "pubkey"; +/// Kind and key of the single context that process-wide settings evaluate. +const RELAY_CONTEXT_KIND: &str = "relay"; +const RELAY_CONTEXT_KEY: &str = "relay"; /// Runtime inputs required to initialize a LaunchDarkly-backed evaluator. #[derive(Clone)] @@ -145,6 +148,12 @@ impl LaunchDarklyEvaluator { } } + /// Whether the SDK has received flag data. Until it has, every evaluation + /// returns the flag's declared default. + pub fn initialized(&self) -> bool { + self.client.initialized() + } + /// Gracefully stop background tasks and flush pending analytics events. /// /// This call blocks the current thread until pending analytics delivery @@ -264,10 +273,16 @@ fn authority_has_explicit_port(authority: &Authority) -> bool { /// Build the LaunchDarkly context used for evaluation. /// -/// The adapter always includes a `community` kind context and optionally adds a -/// global `pubkey` kind context when an actor is present. +/// A community evaluation always includes a `community` kind context and +/// optionally adds a global `pubkey` kind context when an actor is present. +/// The process context is a single `relay` kind context keyed `relay`. fn launchdarkly_context(context: &EvaluationContext) -> Result { - let mut community = ContextBuilder::new(context.community().to_string()); + let Some(community_id) = context.community() else { + let mut relay = ContextBuilder::new(RELAY_CONTEXT_KEY); + relay.kind(RELAY_CONTEXT_KIND); + return relay.build(); + }; + let mut community = ContextBuilder::new(community_id.to_string()); community.kind(COMMUNITY_CONTEXT_KIND); let community = community.build()?; @@ -665,6 +680,34 @@ mod tests { evaluator.close(); } + #[tokio::test] + async fn process_context_targets_relay_kind_and_sees_updates_without_restart() { + let test_data = TestData::new(); + test_data.update( + FlagBuilder::new("process-int") + .variations([FlagValue::from(0_i64), FlagValue::from(5_i64)]) + .fallthrough_variation_index(0) + .variation_index_for_key(kind("relay"), "relay", 1), + ); + let evaluator = started_evaluator(&test_data).await; + let flag = IntegerFlag::new("process-int", -1); + let context = EvaluationContext::for_process(); + + assert_eq!(evaluator.evaluate_int(flag, &context), 5); + test_data.update( + FlagBuilder::new("process-int") + .variations([FlagValue::from(0_i64), FlagValue::from(9_i64)]) + .fallthrough_variation_index(0) + .variation_index_for_key(kind("relay"), "relay", 1), + ); + assert_eq!( + evaluator.evaluate_int(flag, &context), + 9, + "a running evaluator serves the updated value" + ); + evaluator.close(); + } + #[tokio::test] async fn actor_context_targets_integer_variation_by_community_and_pubkey() { let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); diff --git a/crates/buzz-feature-flags/src/lib.rs b/crates/buzz-feature-flags/src/lib.rs index c9169b176b..9b3740a427 100644 --- a/crates/buzz-feature-flags/src/lib.rs +++ b/crates/buzz-feature-flags/src/lib.rs @@ -14,22 +14,33 @@ pub use flags::{BooleanFlag, IntegerFlag}; /// LaunchDarkly-backed evaluator adapter. pub mod launchdarkly; -/// Stable targeting context for one Buzz community and an optional actor. +/// Stable targeting context: one Buzz community and an optional actor, or the +/// relay process itself. /// -/// Community is always required because the same Nostr identity can participate -/// in multiple isolated Buzz communities. Community-only contexts support -/// background and system evaluations that have no actor. +/// Community contexts are the norm, because the same Nostr identity can +/// participate in multiple isolated Buzz communities. Community-only contexts +/// support background and system evaluations that have no actor. The process +/// context is for settings that belong to the relay process rather than to any +/// community, such as concurrency caps shared by every community it serves. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct EvaluationContext { - community: CommunityId, + community: Option, actor_pubkey: Option, } impl EvaluationContext { + /// Construct the context for process-wide settings: no community, no actor. + pub const fn for_process() -> Self { + Self { + community: None, + actor_pubkey: None, + } + } + /// Construct a community-only evaluation context. pub const fn for_community(community: CommunityId) -> Self { Self { - community, + community: Some(community), actor_pubkey: None, } } @@ -37,13 +48,13 @@ impl EvaluationContext { /// Construct an evaluation context for an actor within one community. pub const fn for_actor(community: CommunityId, actor_pubkey: PublicKey) -> Self { Self { - community, + community: Some(community), actor_pubkey: Some(actor_pubkey), } } - /// Community that owns this evaluation. - pub const fn community(&self) -> CommunityId { + /// Community that owns this evaluation; `None` for the process context. + pub const fn community(&self) -> Option { self.community } @@ -128,7 +139,7 @@ mod tests { impl FlagEvaluator for CommunityEvaluator { fn evaluate_bool(&self, flag: BooleanFlag, context: &EvaluationContext) -> bool { - if context.community() == self.enabled_community { + if context.community() == Some(self.enabled_community) { true } else { flag.default() @@ -136,7 +147,7 @@ mod tests { } fn evaluate_int(&self, flag: IntegerFlag, context: &EvaluationContext) -> i64 { - if context.community() == self.enabled_community { + if context.community() == Some(self.enabled_community) { 41 } else { flag.default() @@ -186,7 +197,7 @@ mod tests { } fn evaluate_int(&self, flag: IntegerFlag, context: &EvaluationContext) -> i64 { - if context.community() == self.enabled_community + if context.community() == Some(self.enabled_community) && context.actor_pubkey() == Some(&self.expected_actor) { 99 diff --git a/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.lock b/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.lock deleted file mode 100644 index 089c0108cc..0000000000 --- a/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.lock +++ /dev/null @@ -1,2797 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common 0.1.7", - "generic-array", -] - -[[package]] -name = "aho-corasick" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" -dependencies = [ - "memchr", -] - -[[package]] -name = "android_system_properties" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" -dependencies = [ - "libc", -] - -[[package]] -name = "arrayvec" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "aws-lc-rs" -version = "1.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" -dependencies = [ - "aws-lc-sys", - "untrusted 0.7.1", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", -] - -[[package]] -name = "base16ct" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - -[[package]] -name = "bech32" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" - -[[package]] -name = "bip39" -version = "2.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" -dependencies = [ - "bitcoin_hashes", - "serde", - "unicode-normalization", -] - -[[package]] -name = "bitcoin-consensus-encoding" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6712f9c6fd6785b3b270884e57c441c403dc5d7e19ca45368c97c7a1de3000ec" -dependencies = [ - "bitcoin-internals", - "hex-conservative 1.3.0", - "serde", -] - -[[package]] -name = "bitcoin-internals" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" - -[[package]] -name = "bitcoin-io" -version = "0.1.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" -dependencies = [ - "bitcoin-consensus-encoding", -] - -[[package]] -name = "bitcoin_hashes" -version = "0.14.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" -dependencies = [ - "bitcoin-io", - "hex-conservative 0.2.3", - "serde", -] - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "block-padding" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bs58" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "buzz-core" -version = "0.1.0" -dependencies = [ - "base64 0.22.1", - "chrono", - "hex", - "hmac 0.13.0", - "nostr", - "percent-encoding", - "rand 0.10.3", - "serde", - "serde_json", - "sha2 0.11.0", - "subtle", - "thiserror", - "url", - "uuid", - "zeroize", -] - -[[package]] -name = "buzz-feature-flags" -version = "0.1.0" -dependencies = [ - "buzz-core", - "http", - "launchdarkly-server-sdk", - "thiserror", -] - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "cbc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher", -] - -[[package]] -name = "cc" -version = "1.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" - -[[package]] -name = "chacha20" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "chacha20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "rand_core 0.10.1", -] - -[[package]] -name = "chacha20poly1305" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" -dependencies = [ - "aead", - "chacha20 0.9.1", - "cipher", - "poly1305", - "zeroize", -] - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "cidr" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "579504560394e388085d0c080ea587dfa5c15f7e251b4d5247d1e1a61d1d6928" - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common 0.1.7", - "inout", - "zeroize", -] - -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - -[[package]] -name = "cmov" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" - -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "typenum", -] - -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "ctutils" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" -dependencies = [ - "cmov", -] - -[[package]] -name = "darling" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 3.0.6", -] - -[[package]] -name = "darling_macro" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" -dependencies = [ - "darling_core", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "data-encoding" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" - -[[package]] -name = "defmt" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" -dependencies = [ - "bitflags 1.3.2", - "defmt-macros", -] - -[[package]] -name = "defmt-macros" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" -dependencies = [ - "defmt-parser", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "defmt-parser" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" -dependencies = [ - "thiserror", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "serde_core", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", - "subtle", -] - -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.1", - "const-oid", - "crypto-common 0.2.2", - "ctutils", -] - -[[package]] -name = "displaydoc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "either" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "eventsource-client" -version = "0.17.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96df8cfa11d3c8e4e1a48b81c9c600ecbe8c11d1326f41e1524cc4d42f7bf6d9" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures", - "http", - "launchdarkly-sdk-transport", - "log", - "pin-project", - "rand 0.10.3", - "tokio", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" - -[[package]] -name = "flate2" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" -dependencies = [ - "crc32fast", - "miniz_oxide", - "zlib-rs", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "futures" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-executor" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" - -[[package]] -name = "futures-macro" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "futures-sink" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", -] - -[[package]] -name = "h2" -version = "0.4.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap 2.14.2", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "headers" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" -dependencies = [ - "base64 0.22.1", - "bytes", - "headers-core", - "http", - "httpdate", - "mime", - "sha1", -] - -[[package]] -name = "headers-core" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" -dependencies = [ - "http", -] - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hex-conservative" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db3fef046dca3ca91ee1408a8c1b80ab777e80a4d308d1bf4e7adb3fcb047e08" -dependencies = [ - "arrayvec", -] - -[[package]] -name = "hex-conservative" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271e0d19bcb473b6675739a2b536076b24a082316cb5199ad918edce10c599e8" -dependencies = [ - "arrayvec", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest 0.11.3", -] - -[[package]] -name = "http" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hybrid-array" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" -dependencies = [ - "typenum", -] - -[[package]] -name = "hyper" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-http-proxy" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd1d471ea2f65ba45eddb1d6ab7d58ac2671d2d4ac14da5c6516ae1d97e1327a" -dependencies = [ - "bytes", - "futures-util", - "headers", - "http", - "hyper", - "hyper-rustls", - "hyper-util", - "pin-project-lite", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-native-certs", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-timeout" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" -dependencies = [ - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "libc", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" - -[[package]] -name = "icu_properties" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" - -[[package]] -name = "icu_provider" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - -[[package]] -name = "indexmap" -version = "2.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] - -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "block-padding", - "generic-array", -] - -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jiff" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" -dependencies = [ - "defmt", - "jiff-core", - "jiff-static", - "jiff-tzdb-platform", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", - "windows-link", -] - -[[package]] -name = "jiff-core" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" -dependencies = [ - "defmt", - "log", -] - -[[package]] -name = "jiff-static" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" -dependencies = [ - "jiff-core", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "jiff-tzdb" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" - -[[package]] -name = "jiff-tzdb-platform" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" -dependencies = [ - "jiff-tzdb", -] - -[[package]] -name = "jobserver" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" -dependencies = [ - "getrandom 0.4.3", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "launchdarkly-sdk-transport" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "032d66802c461d7254ed1de887be6ca98ebbe126867d9a60c754baae81c4d10d" -dependencies = [ - "bytes", - "futures", - "http", - "http-body-util", - "hyper", - "hyper-http-proxy", - "hyper-rustls", - "hyper-timeout", - "hyper-util", - "log", - "no-proxy", - "tower", -] - -[[package]] -name = "launchdarkly-server-sdk" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22259877ec581fa56a9f0b982e583e16fa01b71ba6d3f786ef5ccfc6ca6f92ff" -dependencies = [ - "aws-lc-rs", - "bitflags 2.13.2", - "bytes", - "chrono", - "crossbeam-channel", - "data-encoding", - "eventsource-client", - "flate2", - "futures", - "http", - "launchdarkly-sdk-transport", - "launchdarkly-server-sdk-evaluation", - "log", - "lru", - "moka", - "parking_lot", - "rand 0.9.5", - "serde", - "serde_json", - "thiserror", - "tokio", - "tokio-stream", - "uuid", -] - -[[package]] -name = "launchdarkly-server-sdk-evaluation" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76560a130b2a7e7fdb9c56043168844cde483e08e7f0e4a5e178e3888edc4336" -dependencies = [ - "base16ct", - "chrono", - "itertools", - "lazy_static", - "log", - "maplit", - "regex", - "semver", - "serde", - "serde_json", - "serde_with", - "sha1", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "litemap" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" - -[[package]] -name = "lru" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" - -[[package]] -name = "maplit" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "miniz_oxide" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "moka" -version = "0.12.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" -dependencies = [ - "crossbeam-channel", - "crossbeam-epoch", - "crossbeam-utils", - "equivalent", - "parking_lot", - "portable-atomic", - "smallvec", - "tagptr", - "uuid", -] - -[[package]] -name = "no-proxy" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd3f71e44f009513a82ac2612c0c2da7e2924fad776931164317baa9695e150" -dependencies = [ - "cidr", -] - -[[package]] -name = "nostr" -version = "0.44.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ff7b77ef428b40aa2834a6acbae38a0e104c98b306208ca4b87a420d579a4b" -dependencies = [ - "base64 0.22.1", - "bech32", - "bip39", - "bitcoin_hashes", - "cbc", - "chacha20 0.9.1", - "chacha20poly1305", - "getrandom 0.2.17", - "hex", - "instant", - "scrypt", - "secp256k1", - "serde", - "serde_json", - "unicode-normalization", - "url", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "password-hash" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest 0.10.7", - "hmac 0.12.1", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkg-config" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" - -[[package]] -name = "poly1305" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" -dependencies = [ - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - -[[package]] -name = "portable-atomic" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" - -[[package]] -name = "portable-atomic-util" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "potential_utf" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" -dependencies = [ - "chacha20 0.10.2", - "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.13.2", -] - -[[package]] -name = "ref-cast" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "relay-feature-selection-fixture" -version = "0.0.0" -dependencies = [ - "buzz-feature-flags", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted 0.9.0", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustls" -version = "0.23.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" -dependencies = [ - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted 0.9.0", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "salsa20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" -dependencies = [ - "cipher", -] - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "scrypt" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" -dependencies = [ - "password-hash", - "pbkdf2", - "salsa20", - "sha2 0.10.9", -] - -[[package]] -name = "secp256k1" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" -dependencies = [ - "rand 0.8.8", - "secp256k1-sys", - "serde", -] - -[[package]] -name = "secp256k1-sys" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" -dependencies = [ - "cc", -] - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.13.2", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_with" -version = "3.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" -dependencies = [ - "base64 0.23.1", - "bs58", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.14.2", - "jiff", - "schemars 0.9.0", - "schemars 1.2.2", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "sha1" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "digest 0.11.3", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "simd-adler32" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" - -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "synstructure" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "tagptr" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" - -[[package]] -name = "thiserror" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "time" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" - -[[package]] -name = "tokio" -version = "1.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-stream" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", - "tokio-util", -] - -[[package]] -name = "tokio-util" -version = "0.7.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "libc", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tower" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" -dependencies = [ - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "unicode-ident" -version = "1.0.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" - -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common 0.1.7", - "subtle", -] - -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", - "serde_derive", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" -dependencies = [ - "getrandom 0.4.3", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 3.0.6", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "web-sys" -version = "0.3.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "writeable" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" - -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - -[[package]] -name = "zerotrie" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "zlib-rs" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.toml b/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.toml deleted file mode 100644 index 2f6cb93f20..0000000000 --- a/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "relay-feature-selection-fixture" -version = "0.0.0" -edition = "2021" -publish = false - -[workspace] - -[features] -static-feature-flags = [] -environment-feature-flags = [] -launchdarkly-feature-flags = ["buzz-feature-flags/launchdarkly"] - -[dependencies] -buzz-feature-flags = { path = "../../.." } diff --git a/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/src/lib.rs b/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/src/lib.rs deleted file mode 100644 index b979395fee..0000000000 --- a/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/src/lib.rs +++ /dev/null @@ -1,103 +0,0 @@ -#![allow(dead_code)] - -use std::sync::Arc; - -use buzz_feature_flags::{EnvironmentEvaluator, FlagEvaluator}; -#[cfg(feature = "static-feature-flags")] -use buzz_feature_flags::StaticEvaluator; -#[cfg(feature = "launchdarkly-feature-flags")] -use buzz_feature_flags::launchdarkly::{ - LaunchDarklyEvaluator, LaunchDarklyInitError, LaunchDarklyRuntimeConfig, -}; - -#[cfg(not(any( - feature = "static-feature-flags", - feature = "environment-feature-flags", - feature = "launchdarkly-feature-flags", -)))] -compile_error!( - "select exactly one relay feature flag provider feature: static-feature-flags, environment-feature-flags, or launchdarkly-feature-flags" -); - -#[cfg(any( - all(feature = "static-feature-flags", feature = "environment-feature-flags"), - all(feature = "static-feature-flags", feature = "launchdarkly-feature-flags"), - all(feature = "environment-feature-flags", feature = "launchdarkly-feature-flags"), -))] -compile_error!( - "select exactly one relay feature flag provider feature: static-feature-flags, environment-feature-flags, or launchdarkly-feature-flags" -); - -#[cfg(any(feature = "static-feature-flags", feature = "environment-feature-flags"))] -#[derive(Clone, Debug, Default)] -pub struct FeatureFlagRuntimeConfig; - -#[cfg(feature = "launchdarkly-feature-flags")] -#[derive(Clone, Debug)] -pub struct FeatureFlagRuntimeConfig { - pub sdk_key: String, - pub relay_proxy_endpoint: Option, -} - -pub struct FeatureFlagCompositionRoot { - pub feature_flags: Arc, - pub environment_diagnostics: Option>, - #[cfg(feature = "launchdarkly-feature-flags")] - pub launchdarkly_lifecycle: Option>, -} - -#[cfg(feature = "static-feature-flags")] -pub fn build_feature_flag_evaluator(_config: FeatureFlagRuntimeConfig) -> FeatureFlagCompositionRoot { - FeatureFlagCompositionRoot { - feature_flags: Arc::new(StaticEvaluator), - environment_diagnostics: None, - } -} - -#[cfg(feature = "environment-feature-flags")] -pub fn build_feature_flag_evaluator( - _config: FeatureFlagRuntimeConfig, -) -> FeatureFlagCompositionRoot { - let owner = Arc::new(EnvironmentEvaluator::from_process_environment()); - let feature_flags: Arc = owner.clone(); - FeatureFlagCompositionRoot { - feature_flags, - environment_diagnostics: Some(owner), - } -} - -#[cfg(feature = "launchdarkly-feature-flags")] -pub fn build_feature_flag_evaluator( - config: FeatureFlagRuntimeConfig, -) -> Result { - let mut runtime_config = LaunchDarklyRuntimeConfig::new(config.sdk_key); - if let Some(relay_proxy_endpoint) = config.relay_proxy_endpoint { - runtime_config = runtime_config.with_relay_proxy_endpoint(relay_proxy_endpoint); - } - - let owner = Arc::new(LaunchDarklyEvaluator::from_runtime_config(runtime_config)?); - let feature_flags: Arc = owner.clone(); - Ok(FeatureFlagCompositionRoot { - feature_flags, - environment_diagnostics: None, - launchdarkly_lifecycle: Some(owner), - }) -} - -#[cfg(feature = "static-feature-flags")] -pub fn compile_smoke() { - let _ = build_feature_flag_evaluator(FeatureFlagRuntimeConfig); -} - -#[cfg(feature = "environment-feature-flags")] -pub fn compile_smoke() { - let _ = build_feature_flag_evaluator(FeatureFlagRuntimeConfig); -} - -#[cfg(feature = "launchdarkly-feature-flags")] -pub fn compile_smoke() { - let _ = build_feature_flag_evaluator(FeatureFlagRuntimeConfig { - sdk_key: "fixture-sdk-key".to_owned(), - relay_proxy_endpoint: None, - }); -} diff --git a/crates/buzz-feature-flags/tests/relay_feature_selection_contract.rs b/crates/buzz-feature-flags/tests/relay_feature_selection_contract.rs deleted file mode 100644 index 120ad58787..0000000000 --- a/crates/buzz-feature-flags/tests/relay_feature_selection_contract.rs +++ /dev/null @@ -1,229 +0,0 @@ -use std::{ - env, - ffi::OsString, - path::{Path, PathBuf}, - process::Command, -}; -use tempfile::TempDir; - -const FIXTURE_LOCK_REGEN_RECIPE: &str = - "cargo generate-lockfile --manifest-path crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.toml"; - -fn fixture_manifest() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/relay-feature-selection/Cargo.toml") -} - -fn fixture_lockfile() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/relay-feature-selection/Cargo.lock") -} - -fn crate_readme() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("README.md") -} - -fn cargo_bin() -> OsString { - env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo")) -} - -fn cargo_check(target_dir: &Path, case_name: &str, features: &[&str]) -> std::process::Output { - let mut command = Command::new(cargo_bin()); - command - .current_dir(env!("CARGO_MANIFEST_DIR")) - .arg("check") - .arg("--quiet") - .arg("--locked") - .arg("--manifest-path") - .arg(fixture_manifest()) - .arg("--no-default-features") - .env("CARGO_TARGET_DIR", target_dir); - - if !features.is_empty() { - command.arg("--features").arg(features.join(",")); - } - - command - .output() - .unwrap_or_else(|error| { - panic!( - "failed to run cargo check for {case_name}: {error}\nfixture lock regeneration recipe: `{FIXTURE_LOCK_REGEN_RECIPE}`" - ) - }) -} - -fn format_valid_case_failed_status(case_name: &str, stdout: &str, stderr: &str) -> String { - format!( - "expected {case_name} to compile successfully\nstdout:\n{stdout}\nstderr:\n{stderr}\nregenerate fixture lock with `{FIXTURE_LOCK_REGEN_RECIPE}`" - ) -} - -fn format_invalid_case_missing_exact_one(case_name: &str, stderr: &str) -> String { - format!( - "expected {case_name} failure to explain the exact-one contract\nstderr:\n{stderr}\nregenerate fixture lock with `{FIXTURE_LOCK_REGEN_RECIPE}`" - ) -} - -fn assert_valid_case_success(case_name: &str, output: &std::process::Output) { - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - output.status.success(), - "{}", - format_valid_case_failed_status(case_name, &stdout, &stderr) - ); -} - -fn assert_invalid_case_has_exact_one_diagnostic(case_name: &str, output: &std::process::Output) { - assert!( - !output.status.success(), - "expected {case_name} to fail compilation" - ); - - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("select exactly one relay feature flag provider feature"), - "{}", - format_invalid_case_missing_exact_one(case_name, &stderr) - ); -} - -#[test] -fn relay_fixture_lock_regeneration_recipe_is_documented() { - let readme = std::fs::read_to_string(crate_readme()).expect("read crate README"); - assert!( - readme.contains(FIXTURE_LOCK_REGEN_RECIPE), - "README.md must document fixture lock regeneration with `{FIXTURE_LOCK_REGEN_RECIPE}`" - ); -} - -#[test] -fn relay_readme_documents_fail_closed_launchdarkly_startup_contract() { - let readme = std::fs::read_to_string(crate_readme()).expect("read crate README"); - - assert!( - readme.contains("start_with_default_executor_and_wait"), - "README.md must show LaunchDarkly startup via start_with_default_executor_and_wait" - ); - assert!( - readme.contains("async fn build_feature_flag_evaluator"), - "README.md must declare LaunchDarkly build_feature_flag_evaluator as async" - ); - assert!( - readme.contains( - "start_with_default_executor_and_wait(Duration::from_secs(5))\n .await" - ), - "README.md must await start_with_default_executor_and_wait" - ); - assert!( - readme.contains("fail-closed"), - "README.md must state fail-closed startup policy" - ); - assert!( - readme.contains("exact-one compile_error guards"), - "README.md must require exact-one compile_error guards in the consumer" - ); - assert!( - readme.contains("future flag registry"), - "README.md must defer environment-name uniqueness enforcement to a future registry" - ); - assert!( - readme.contains("kind `community` plus") - && readme.contains("optional global kind `pubkey`"), - "README.md must document LaunchDarkly adapter context kinds" - ); - assert!( - readme.contains("rollouts must set `contextKind` to a kind present") - && readme.contains("in evaluation"), - "README.md must document rollout contextKind requirements" - ); - assert!( - readme.contains("omits `contextKind` defaults") && readme.contains("to `user`;"), - "README.md must document LaunchDarkly default rollout context kind" - ); - assert!( - readme.contains("bucket as") - && readme.contains("zero and pick the first positive-weight variation"), - "README.md must document missing-kind rollout bucketing behavior" - ); - assert!( - readme.contains("close()` blocks the calling thread") - && readme.contains("while it flushes analytics"), - "README.md must document blocking LaunchDarkly close semantics" - ); - assert!( - readme.contains("offload it to a blocking shutdown path"), - "README.md must document async shutdown offloading guidance for close()" - ); -} - -#[test] -fn valid_case_failed_status_formatter_includes_fixture_lock_regeneration_recipe() { - let message = format_valid_case_failed_status( - "static", - "", - "error: lock file needs update but --locked was passed", - ); - assert!( - message.contains(FIXTURE_LOCK_REGEN_RECIPE), - "valid-case failure formatter must include fixture lock recipe\nmessage:\n{message}" - ); -} - -#[test] -fn invalid_case_missing_exact_one_formatter_includes_fixture_lock_regeneration_recipe() { - let message = - format_invalid_case_missing_exact_one("none", "error: unrelated nested cargo failure"); - assert!( - message.contains(FIXTURE_LOCK_REGEN_RECIPE), - "invalid-case failure formatter must include fixture lock recipe\nmessage:\n{message}" - ); -} - -#[test] -fn relay_artifact_provider_features_require_exactly_one_selection() { - assert!( - fixture_lockfile().is_file(), - "relay feature selection fixture must check in Cargo.lock for locked nested cargo runs\nregenerate it with `{FIXTURE_LOCK_REGEN_RECIPE}`" - ); - - let target_dir = TempDir::new().expect("temp target dir"); - - let valid_cases = [ - ("static", &["static-feature-flags"][..]), - ("environment", &["environment-feature-flags"][..]), - ("launchdarkly", &["launchdarkly-feature-flags"][..]), - ]; - - for (case_name, features) in valid_cases { - let output = cargo_check(target_dir.path(), case_name, features); - assert_valid_case_success(case_name, &output); - } - - let invalid_cases = [ - ("none", &[][..]), - ( - "static-and-environment", - &["static-feature-flags", "environment-feature-flags"][..], - ), - ( - "static-and-launchdarkly", - &["static-feature-flags", "launchdarkly-feature-flags"][..], - ), - ( - "environment-and-launchdarkly", - &["environment-feature-flags", "launchdarkly-feature-flags"][..], - ), - ( - "all-three", - &[ - "static-feature-flags", - "environment-feature-flags", - "launchdarkly-feature-flags", - ][..], - ), - ]; - - for (case_name, features) in invalid_cases { - let output = cargo_check(target_dir.path(), case_name, features); - assert_invalid_case_has_exact_one_diagnostic(case_name, &output); - } -} diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index 99350ab7ec..53cf3f4dc7 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -19,6 +19,9 @@ path = "src/main.rs" buzz-core = { workspace = true } buzz-conformance = { workspace = true } buzz-db = { workspace = true } +# The relay chooses its flag provider at startup (BUZZ_FEATURE_FLAGS_PROVIDER), +# so every build carries the LaunchDarkly adapter; it is inert unless selected. +buzz-feature-flags = { workspace = true, features = ["launchdarkly"] } buzz-datastore-tracing = { workspace = true } buzz-deletion = { workspace = true } buzz-auth = { workspace = true } diff --git a/crates/buzz-relay/src/access.rs b/crates/buzz-relay/src/access.rs index 2174d0d2d6..03baa420c0 100644 --- a/crates/buzz-relay/src/access.rs +++ b/crates/buzz-relay/src/access.rs @@ -755,6 +755,7 @@ impl AuthResult { O::RelayMembershipCheckError => AuthReason::MembershipUnavailable, O::NotRelayMember => AuthReason::NotRelayMember, O::PairingMismatch => AuthReason::KeyMismatch, + O::ConcurrencyLimit => AuthReason::ConcurrencyLimit, O::Timeout => AuthReason::Timeout, O::Disconnect => AuthReason::Disconnect, O::Shutdown => AuthReason::Shutdown, diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 41a28de2b7..b9fa7a59f5 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -426,6 +426,18 @@ pub async fn provision_community( Err(msg) if msg.starts_with("actor not authorized") => { Err(api_error(StatusCode::FORBIDDEN, &msg)) } + Err(msg) if msg.starts_with("nip_fi_binding_required:") => Err(coded_api_error( + StatusCode::BAD_REQUEST, + "nip_fi_binding_required", + &msg, + )), + Err(msg) if msg.starts_with("initial_policy_requires_create_only:") => { + Err(coded_api_error( + StatusCode::BAD_REQUEST, + "initial_policy_requires_create_only", + &msg, + )) + } Err(msg) if msg.starts_with("limit_reached:") => { Err(coded_api_error(StatusCode::CONFLICT, "limit_reached", &msg)) } @@ -1234,6 +1246,7 @@ pub async fn no_store_policy_response( #[cfg(test)] mod postgres_tests { + mod initial_policy_postgres_tests; use std::{ collections::HashSet, sync::{Arc, Mutex}, diff --git a/crates/buzz-relay/src/api/operator/postgres_tests/initial_policy_postgres_tests.rs b/crates/buzz-relay/src/api/operator/postgres_tests/initial_policy_postgres_tests.rs new file mode 100644 index 0000000000..d0dc9ff1ba --- /dev/null +++ b/crates/buzz-relay/src/api/operator/postgres_tests/initial_policy_postgres_tests.rs @@ -0,0 +1,205 @@ +mod postgres_tests { + use super::super::*; + + async fn create( + state: &Arc, + operator: &Keys, + owner: &Keys, + host: &str, + create_only: bool, + policy: Option, + ) -> axum::response::Response { + let mut body = serde_json::json!({"host":host,"initial_owner_pubkey":owner.public_key().to_hex(),"create_only":create_only}); + if let Some(policy) = policy { + body["initial_requires_nip_fi"] = policy; + } + signed_operator_request( + state.clone(), + operator, + "POST", + "/operator/communities", + Some(body.to_string()), + ) + .await + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn initial_policy_and_same_owner_replay_journey() { + let operator = Keys::generate(); + let (binding, reserved, control) = reserved_host_policy(); + let mut state = + operator_test_state_with_policy(std::slice::from_ref(&operator), Some(binding)) + .await + .unwrap(); + let owners: Vec<_> = (0..4).map(|_| Keys::generate()).collect(); + let owner = &owners[3]; + assert!(!state.config.nip_fi_upgrades_enabled); + for (index, policy) in [ + None, + Some(Value::Null), + Some(Value::Bool(false)), + Some(Value::Bool(true)), + ] + .into_iter() + .enumerate() + { + let host = control.replacen("acme.", &format!("policy-{index}."), 1); + let required = policy == Some(Value::Bool(true)); + let owner = &owners[index]; + let response = create(&state, &operator, owner, &host, true, policy).await; + assert!(response.status().is_success()); + let body = read_json(response).await; + assert_eq!(body["status"], "created"); + assert_eq!(body["requires_nip_fi"], required); + let record = state + .db + .lookup_community_by_host(&host) + .await + .unwrap() + .unwrap(); + let stored = state + .db + .community_auth_policy(record.id) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.requires_nip_fi, required); + assert_eq!(stored.policy_generation, i64::from(required)); + let response = create( + &state, + &operator, + owner, + &host, + true, + Some(Value::Bool(!required)), + ) + .await; + assert!(response.status().is_success()); + let replay = read_json(response).await; + assert_eq!(replay["status"], "existed"); + assert_eq!(replay["requires_nip_fi"], required); + assert_eq!( + state + .db + .community_auth_policy(record.id) + .await + .unwrap() + .unwrap(), + stored + ); + } + for value in [false, true] { + let response = create( + &state, + &operator, + owner, + &control, + false, + Some(Value::Bool(value)), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + read_json(response).await["code"], + "initial_policy_requires_create_only" + ); + } + let refused = create( + &state, + &operator, + owner, + &reserved, + true, + Some(Value::Bool(true)), + ) + .await; + assert_eq!(refused.status(), StatusCode::BAD_REQUEST); + assert_eq!(read_json(refused).await["code"], "reserved_host"); + let outside = format!("outside-{}.example", Uuid::new_v4().simple()); + let refusal = create( + &state, + &operator, + owner, + &outside, + true, + Some(Value::Bool(true)), + ) + .await; + assert_eq!(refusal.status(), StatusCode::BAD_REQUEST); + assert_eq!(read_json(refusal).await["code"], "nip_fi_binding_required"); + assert!(state + .db + .lookup_community_by_host(&outside) + .await + .unwrap() + .is_none()); + let host = control.replacen("acme.", "policy-3.", 1); + let record = state + .db + .lookup_community_by_host(&host) + .await + .unwrap() + .unwrap(); + sqlx::query("UPDATE communities SET requires_nip_fi = false, policy_generation = policy_generation + 1 WHERE id = $1") + .bind(record.id.as_uuid()).execute(state.db.pool()).await.unwrap(); + let disabled = state + .db + .community_auth_policy(record.id) + .await + .unwrap() + .unwrap(); + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_provision_binding = + None; + let response = create( + &state, + &operator, + owner, + &host, + true, + Some(Value::Bool(true)), + ) + .await; + assert!(response.status().is_success()); + let replay = read_json(response).await; + assert_eq!(replay["status"], "existed"); + assert_eq!(replay["requires_nip_fi"], false); + assert_eq!( + state + .db + .community_auth_policy(record.id) + .await + .unwrap() + .unwrap(), + disabled + ); + let outside = format!("unbound-{}.example", Uuid::new_v4().simple()); + let response = create( + &state, + &operator, + owner, + &outside, + true, + Some(Value::Bool(true)), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(read_json(response).await["code"], "nip_fi_binding_required"); + assert!(state + .db + .lookup_community_by_host(&outside) + .await + .unwrap() + .is_none()); + let unauthorized = create( + &state, + &Keys::generate(), + owner, + &outside, + true, + Some(Value::Bool(true)), + ) + .await; + assert_eq!(unauthorized.status(), StatusCode::FORBIDDEN); + } +} diff --git a/crates/buzz-relay/src/concurrency_limits.rs b/crates/buzz-relay/src/concurrency_limits.rs new file mode 100644 index 0000000000..56133863c9 --- /dev/null +++ b/crates/buzz-relay/src/concurrency_limits.rs @@ -0,0 +1,288 @@ +//! Operator-tunable concurrency caps, re-read from feature flags while the +//! relay runs. +//! +//! Each cap is a [`ConcurrencyGate`] with a ceiling fixed at startup and an +//! integer flag evaluated in the process context: +//! +//! | Flag | Gate | Ceiling | +//! |------|------|---------| +//! | `buzz.relay.concurrency.history-reads` | subscription-history reads on the reader pool | the reader-pool share [`buzz_db`] reserves for history | +//! | `buzz.relay.concurrency.auth` | verified NIP-42 AUTH frames in their database checks | none | +//! +//! A positive value sets the limit, clamped to the ceiling; zero, a negative +//! value, or no value leaves the gate at its ceiling. A flag can therefore +//! only tighten a cap, never loosen one past what the relay starts with, and +//! a deleted flag or a provider that has not initialized puts every gate back +//! at its ceiling. Once LaunchDarkly has delivered values, they stay in force +//! through a later outage (the SDK serves its last-known data), so a cap +//! tightened during an incident is not lost when the provider connection +//! drops. +//! A lowered gate never cancels admitted work: it admits nothing new until +//! the count in flight falls under the new limit. +//! +//! The flags are re-read every [`REFRESH_INTERVAL`], so a change reaches every +//! pod within that interval plus the provider's own propagation delay, with no +//! restart. `buzz_concurrency_limit{gate}` reports each applied limit, with +//! `0` meaning no limit. A refresh that panics is logged and retried on the +//! next tick, so one bad evaluation cannot freeze the caps for the life of +//! the pod. See `docs/operator-concurrency-caps.md` for operating them. + +use std::time::Duration; + +use buzz_db::ConcurrencyGate; +use buzz_feature_flags::{EvaluationContext, IntegerFlag}; +use tokio_util::sync::CancellationToken; +use tracing::{error, info}; + +use crate::feature_flags::FeatureFlags; + +/// Cap on subscription-history reads holding a reader connection. +pub const HISTORY_READS_FLAG: IntegerFlag = + IntegerFlag::new("buzz.relay.concurrency.history-reads", 0); +/// Cap on verified AUTH frames running their database checks. +pub const AUTH_FLAG: IntegerFlag = IntegerFlag::new("buzz.relay.concurrency.auth", 0); + +/// How often the flags are re-read. +pub const REFRESH_INTERVAL: Duration = Duration::from_secs(5); + +/// One gate whose limit a flag controls. +#[derive(Clone, Debug)] +pub struct ManagedGate { + name: &'static str, + flag: IntegerFlag, + gate: ConcurrencyGate, +} + +impl ManagedGate { + /// `gate`, controlled by `flag` and reported as `name`. + pub fn new(name: &'static str, flag: IntegerFlag, gate: ConcurrencyGate) -> Self { + Self { name, flag, gate } + } +} + +/// The relay's managed gates: history reads and AUTH. +pub fn relay_gates(state: &crate::state::AppState) -> Vec { + vec![ + ManagedGate::new( + "history_reads", + HISTORY_READS_FLAG, + state.db.history_read_gate().clone(), + ), + ManagedGate::new("auth", AUTH_FLAG, state.auth_gate.clone()), + ] +} + +/// The limit a flag value asks for on a gate with `ceiling`. +fn requested_limit(value: i64, ceiling: usize) -> usize { + if value <= 0 { + return ceiling; + } + usize::try_from(value).map_or(ceiling, |value| value.min(ceiling)) +} + +/// Evaluate every gate's flag once and apply the result. +pub fn apply(flags: &FeatureFlags, gates: &[ManagedGate]) { + let context = EvaluationContext::for_process(); + for managed in gates { + let ceiling = managed.gate.ceiling(); + let value = flags.evaluator().evaluate_int(managed.flag, &context); + let previous = managed.gate.limit(); + let applied = managed.gate.set_limit(requested_limit(value, ceiling)); + if applied != previous { + info!( + gate = managed.name, + limit = applied, + previous, + ceiling, + "concurrency limit changed" + ); + } + let reported = if applied >= tokio::sync::Semaphore::MAX_PERMITS { + 0.0 + } else { + applied as f64 + }; + metrics::gauge!("buzz_concurrency_limit", "gate" => managed.name).set(reported); + } + flags.report(); +} + +/// Apply the flags every [`REFRESH_INTERVAL`] (the first time immediately) +/// until `cancel` fires. A panicking refresh is logged and counted +/// (`buzz_concurrency_limit_refresh_panics_total`), and the next tick tries +/// again. +pub async fn run( + flags: std::sync::Arc, + gates: Vec, + cancel: CancellationToken, +) { + let mut interval = tokio::time::interval(REFRESH_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + biased; + _ = cancel.cancelled() => break, + _ = interval.tick() => { + let refresh = std::panic::AssertUnwindSafe(|| apply(&flags, &gates)); + if std::panic::catch_unwind(refresh).is_err() { + metrics::counter!("buzz_concurrency_limit_refresh_panics_total").increment(1); + error!("concurrency limit refresh panicked; retrying on the next tick"); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_feature_flags::{BooleanFlag, FlagEvaluator}; + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + + /// A provider whose values the test changes while the relay "runs". + #[derive(Default)] + struct LiveValues(Mutex>); + + impl LiveValues { + fn set(&self, flag: IntegerFlag, value: Option) { + let mut values = self.0.lock().expect("values"); + match value { + Some(value) => values.insert(flag.key(), value), + None => values.remove(flag.key()), + }; + } + } + + impl FlagEvaluator for LiveValues { + fn evaluate_bool(&self, flag: BooleanFlag, _: &EvaluationContext) -> bool { + flag.default() + } + + fn evaluate_int(&self, flag: IntegerFlag, context: &EvaluationContext) -> i64 { + assert_eq!( + context.community(), + None, + "caps evaluate in the process context" + ); + self.0 + .lock() + .expect("values") + .get(flag.key()) + .copied() + .unwrap_or(flag.default()) + } + } + + #[test] + fn flag_values_only_tighten_and_reset_to_the_ceiling() { + let values = Arc::new(LiveValues::default()); + let flags = FeatureFlags::from_evaluator("test", values.clone()); + let history = ConcurrencyGate::new(20); + let auth = ConcurrencyGate::unbounded(); + let gates = vec![ + ManagedGate::new("history_reads", HISTORY_READS_FLAG, history.clone()), + ManagedGate::new("auth", AUTH_FLAG, auth.clone()), + ]; + + // Each row: the flag values, then the limits applied. + let rows: [(Option, Option, usize, usize); 6] = [ + (None, None, 20, auth.ceiling()), + (Some(8), Some(50), 8, 50), + (Some(500), Some(i64::MAX), 20, auth.ceiling()), + (Some(0), Some(-3), 20, auth.ceiling()), + (Some(1), Some(1), 1, 1), + (None, None, 20, auth.ceiling()), + ]; + for (history_value, auth_value, history_limit, auth_limit) in rows { + values.set(HISTORY_READS_FLAG, history_value); + values.set(AUTH_FLAG, auth_value); + apply(&flags, &gates); + assert_eq!(history.limit(), history_limit, "history {history_value:?}"); + assert_eq!(auth.limit(), auth_limit, "auth {auth_value:?}"); + } + } + + #[tokio::test(start_paused = true)] + async fn the_refresh_loop_applies_changes_without_a_restart() { + let values = Arc::new(LiveValues::default()); + let flags = Arc::new(FeatureFlags::from_evaluator("test", values.clone())); + let gate = ConcurrencyGate::new(10); + let cancel = CancellationToken::new(); + let task = tokio::spawn(run( + flags, + vec![ManagedGate::new( + "history_reads", + HISTORY_READS_FLAG, + gate.clone(), + )], + cancel.clone(), + )); + + values.set(HISTORY_READS_FLAG, Some(3)); + tokio::time::sleep(REFRESH_INTERVAL + Duration::from_millis(1)).await; + assert_eq!(gate.limit(), 3, "the next refresh applies the new value"); + + values.set(HISTORY_READS_FLAG, None); + tokio::time::sleep(REFRESH_INTERVAL).await; + assert_eq!(gate.limit(), 10, "removing the flag restores the ceiling"); + + cancel.cancel(); + task.await.expect("the loop stops on cancel"); + } + + /// An evaluator that panics on its first `remaining_panics` calls, then + /// reads a fixed value. + struct PanicsFirst { + remaining_panics: std::sync::atomic::AtomicUsize, + value: i64, + } + + impl FlagEvaluator for PanicsFirst { + fn evaluate_bool(&self, flag: BooleanFlag, _: &EvaluationContext) -> bool { + flag.default() + } + + fn evaluate_int(&self, _: IntegerFlag, _: &EvaluationContext) -> i64 { + use std::sync::atomic::Ordering; + let remaining = self.remaining_panics.load(Ordering::SeqCst); + if remaining > 0 { + self.remaining_panics.store(remaining - 1, Ordering::SeqCst); + panic!("injected evaluation panic"); + } + self.value + } + } + + #[tokio::test(start_paused = true)] + async fn a_panicking_refresh_does_not_stop_the_loop() { + let flags = Arc::new(FeatureFlags::from_evaluator( + "test", + Arc::new(PanicsFirst { + remaining_panics: 1.into(), + value: 4, + }), + )); + let gate = ConcurrencyGate::new(10); + let cancel = CancellationToken::new(); + let task = tokio::spawn(run( + flags, + vec![ManagedGate::new( + "history_reads", + HISTORY_READS_FLAG, + gate.clone(), + )], + cancel.clone(), + )); + + // The first tick panics; the gate keeps its ceiling. + tokio::time::sleep(Duration::from_millis(1)).await; + assert_eq!(gate.limit(), 10, "a panicked refresh applies nothing"); + tokio::time::sleep(REFRESH_INTERVAL).await; + assert_eq!(gate.limit(), 4, "the next tick applies the flag"); + + cancel.cancel(); + task.await + .expect("the loop survives the panic and stops on cancel"); + } +} diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index 8cf56875a2..8b32bb4823 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -1854,6 +1854,7 @@ pub(crate) mod tests { AuthOutcome::RelayMembershipCheckError, AuthOutcome::NotRelayMember, AuthOutcome::PairingMismatch, + AuthOutcome::ConcurrencyLimit, ] { crate::metrics::record_auth_attempt_started(); let (denied, _rx) = test_conn_with_auth(pending_state()); diff --git a/crates/buzz-relay/src/feature_flags.rs b/crates/buzz-relay/src/feature_flags.rs new file mode 100644 index 0000000000..7f207f5919 --- /dev/null +++ b/crates/buzz-relay/src/feature_flags.rs @@ -0,0 +1,359 @@ +//! The relay's feature-flag provider, chosen once at startup. +//! +//! `BUZZ_FEATURE_FLAGS_PROVIDER` selects exactly one evaluator for the life of +//! the process (see `crates/buzz-feature-flags/README.md`): +//! +//! | Value | Evaluator | +//! |-------|-----------| +//! | unset, `static` | every flag returns its declared default | +//! | `environment` | `BUZZ_FEATURE_FLAG_*` values, read once at startup | +//! | `launchdarkly` | LaunchDarkly, keyed by `BUZZ_LAUNCHDARKLY_SDK_KEY`, optionally through the Relay Proxy at `BUZZ_LAUNCHDARKLY_RELAY_PROXY_URL` | +//! +//! The variable is deliberately outside the `BUZZ_FEATURE_FLAG_` namespace the +//! environment evaluator reads, so it can never be mistaken for a flag. +//! +//! Local configuration errors (an unknown provider, a missing SDK key, a +//! malformed proxy URL) fail startup. LaunchDarkly itself being unreachable +//! does not: the relay starts on declared defaults and the SDK keeps +//! connecting in the background, because no safety invariant may depend on +//! remote flag availability. After LaunchDarkly first delivers data, a lost +//! connection leaves its last-known values in force until it reconnects. + +use std::sync::Arc; +use std::time::Duration; + +use buzz_feature_flags::launchdarkly::{LaunchDarklyEvaluator, LaunchDarklyRuntimeConfig}; +use buzz_feature_flags::{EnvironmentEvaluator, FlagEvaluator, StaticEvaluator}; +use tracing::{info, warn}; + +/// Environment variable naming the provider. +pub const PROVIDER_ENV: &str = "BUZZ_FEATURE_FLAGS_PROVIDER"; +const SDK_KEY_ENV: &str = "BUZZ_LAUNCHDARKLY_SDK_KEY"; +const RELAY_PROXY_ENV: &str = "BUZZ_LAUNCHDARKLY_RELAY_PROXY_URL"; + +/// How long startup waits for LaunchDarkly's first flag data before serving +/// on declared defaults. +const LAUNCHDARKLY_START_WAIT: Duration = Duration::from_secs(3); + +/// How long shutdown waits for LaunchDarkly's final analytics flush. The flush +/// has no deadline of its own, and an unreachable events endpoint must not +/// spend the pod's termination grace. +const LAUNCHDARKLY_CLOSE_WAIT: Duration = Duration::from_secs(2); + +/// The provider selected by [`PROVIDER_ENV`]. +#[derive(Debug)] +enum Provider { + Static, + Environment, + LaunchDarkly(LaunchDarklyRuntimeConfig), +} + +/// Parse the provider from `get`, which reads one environment variable. +fn provider_from(get: impl Fn(&str) -> Option) -> anyhow::Result { + let value = get(PROVIDER_ENV).unwrap_or_default(); + match value.trim() { + "" | "static" => Ok(Provider::Static), + "environment" => Ok(Provider::Environment), + "launchdarkly" => { + let sdk_key = get(SDK_KEY_ENV) + .filter(|key| !key.trim().is_empty()) + .ok_or_else(|| { + anyhow::anyhow!("{PROVIDER_ENV}=launchdarkly requires {SDK_KEY_ENV}") + })?; + let mut config = LaunchDarklyRuntimeConfig::new(sdk_key); + if let Some(endpoint) = get(RELAY_PROXY_ENV).filter(|url| !url.trim().is_empty()) { + config = config.with_relay_proxy_endpoint(endpoint); + } + Ok(Provider::LaunchDarkly(config)) + } + other => anyhow::bail!( + "{PROVIDER_ENV}={other:?} is not a feature flag provider; \ + use static, environment, or launchdarkly" + ), + } +} + +/// The process's one flag evaluator, plus the concrete handle its provider +/// needs for diagnostics and shutdown. +pub struct FeatureFlags { + evaluator: Arc, + provider: &'static str, + environment: Option>, + launchdarkly: Option>, +} + +impl FeatureFlags { + /// Build the provider [`PROVIDER_ENV`] selects. Fails only on local + /// configuration errors; see the module docs. + pub async fn from_env() -> anyhow::Result { + let provider = provider_from(|name| std::env::var(name).ok())?; + let flags = Self::build(provider, LAUNCHDARKLY_START_WAIT).await?; + info!(provider = flags.provider, "feature flag provider selected"); + Ok(flags) + } + + async fn build(provider: Provider, start_wait: Duration) -> anyhow::Result { + Ok(match provider { + Provider::Static => Self::from_evaluator("static", Arc::new(StaticEvaluator)), + Provider::Environment => { + let owner = Arc::new(EnvironmentEvaluator::from_process_environment()); + Self { + evaluator: owner.clone(), + provider: "environment", + environment: Some(owner), + launchdarkly: None, + } + } + Provider::LaunchDarkly(config) => { + let owner = Arc::new( + LaunchDarklyEvaluator::from_runtime_config(config) + .map_err(|e| anyhow::anyhow!("LaunchDarkly configuration: {e}"))?, + ); + if let Err(error) = owner.start_with_default_executor_and_wait(start_wait).await { + warn!( + %error, + "LaunchDarkly has not delivered flags; serving declared defaults until it does" + ); + } + Self { + evaluator: owner.clone(), + provider: "launchdarkly", + environment: None, + launchdarkly: Some(owner), + } + } + }) + } + + /// Flags backed by `evaluator` alone (tests, embedding). + pub fn from_evaluator(provider: &'static str, evaluator: Arc) -> Self { + Self { + evaluator, + provider, + environment: None, + launchdarkly: None, + } + } + + /// The evaluator every consumer shares. + pub fn evaluator(&self) -> &dyn FlagEvaluator { + self.evaluator.as_ref() + } + + /// Report provider health after a round of evaluations: log malformed + /// environment values once each, and publish whether the provider has + /// initialized (`buzz_feature_flags_ready`). It does not track the + /// connection afterwards: an initialized LaunchDarkly client keeps serving + /// its last-known values through an outage. + pub fn report(&self) { + if let Some(environment) = &self.environment { + for diagnostic in environment.take_diagnostics() { + warn!( + ?diagnostic, + "malformed feature flag environment value; using its default" + ); + } + } + let ready = self + .launchdarkly + .as_ref() + .is_none_or(|launchdarkly| launchdarkly.initialized()); + metrics::gauge!("buzz_feature_flags_ready", "provider" => self.provider).set(if ready { + 1.0 + } else { + 0.0 + }); + } + + /// Stop the provider's background work, waiting at most + /// [`LAUNCHDARKLY_CLOSE_WAIT`]. + pub async fn close(&self) { + self.close_within(LAUNCHDARKLY_CLOSE_WAIT).await; + } + + /// LaunchDarkly's close blocks while it flushes analytics, so it runs on + /// its own detached thread: past `wait`, shutdown moves on and process + /// exit ends the flush. (The blocking pool would not do: the runtime + /// waits for its tasks when it drops.) + async fn close_within(&self, wait: Duration) { + let Some(launchdarkly) = self.launchdarkly.clone() else { + return; + }; + let (closed_tx, closed_rx) = tokio::sync::oneshot::channel(); + let spawned = std::thread::Builder::new() + .name("launchdarkly-close".into()) + .spawn(move || { + launchdarkly.close(); + let _ = closed_tx.send(()); + }); + if let Err(error) = spawned { + warn!(%error, "LaunchDarkly shutdown failed"); + return; + } + match tokio::time::timeout(wait, closed_rx).await { + Ok(Ok(())) => {} + Ok(Err(_)) => warn!("LaunchDarkly shutdown failed"), + Err(_) => warn!( + ?wait, + "LaunchDarkly did not finish its final flush; shutting down without it" + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn parse(pairs: &[(&str, &str)]) -> anyhow::Result { + let env: HashMap = pairs + .iter() + .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) + .collect(); + provider_from(|name| env.get(name).cloned()) + } + + #[test] + fn provider_selection_is_one_explicit_choice() { + assert!(matches!(parse(&[]), Ok(Provider::Static))); + assert!(matches!( + parse(&[(PROVIDER_ENV, "static")]), + Ok(Provider::Static) + )); + assert!(matches!( + parse(&[(PROVIDER_ENV, " environment ")]), + Ok(Provider::Environment) + )); + // An SDK key alone never selects LaunchDarkly. + assert!(matches!( + parse(&[(SDK_KEY_ENV, "sdk-key")]), + Ok(Provider::Static) + )); + + let Ok(Provider::LaunchDarkly(config)) = parse(&[ + (PROVIDER_ENV, "launchdarkly"), + (SDK_KEY_ENV, "sdk-key"), + (RELAY_PROXY_ENV, "http://ld-proxy.example.org:8030"), + ]) else { + panic!("launchdarkly with a key selects LaunchDarkly"); + }; + assert_eq!(config.sdk_key, "sdk-key"); + assert_eq!( + config.relay_proxy_endpoint.as_deref(), + Some("http://ld-proxy.example.org:8030") + ); + let Ok(Provider::LaunchDarkly(config)) = parse(&[ + (PROVIDER_ENV, "launchdarkly"), + (SDK_KEY_ENV, "sdk-key"), + (RELAY_PROXY_ENV, ""), + ]) else { + panic!("an empty proxy URL means no proxy"); + }; + assert_eq!(config.relay_proxy_endpoint, None); + } + + #[test] + fn provider_misconfiguration_fails_startup() { + for (pairs, needle) in [ + (&[(PROVIDER_ENV, "launchdarkly")][..], SDK_KEY_ENV), + ( + &[(PROVIDER_ENV, "launchdarkly"), (SDK_KEY_ENV, " ")][..], + SDK_KEY_ENV, + ), + ( + &[(PROVIDER_ENV, "LaunchDarkly")][..], + "not a feature flag provider", + ), + ( + &[(PROVIDER_ENV, "flagsmith")][..], + "not a feature flag provider", + ), + ] { + let error = parse(pairs) + .expect_err("misconfiguration must fail") + .to_string(); + assert!(error.contains(needle), "{pairs:?}: {error}"); + } + } + + #[tokio::test] + async fn an_unreachable_launchdarkly_starts_on_defaults() { + // The relay installs its rustls provider before building flags; the + // SDK's HTTP client needs one, and several are compiled in. + let _already_installed = rustls::crypto::ring::default_provider().install_default(); + // Nothing listens on the discard port, so the SDK never initializes. + let provider = parse(&[ + (PROVIDER_ENV, "launchdarkly"), + (SDK_KEY_ENV, "sdk-key"), + (RELAY_PROXY_ENV, "http://127.0.0.1:9"), + ]) + .expect("valid configuration"); + let flags = FeatureFlags::build(provider, Duration::from_millis(50)) + .await + .expect("an unreachable provider must not fail startup"); + let flag = buzz_feature_flags::IntegerFlag::new("buzz.relay.concurrency.auth", 0); + assert_eq!( + flags + .evaluator() + .evaluate_int(flag, &buzz_feature_flags::EvaluationContext::for_process()), + 0, + "an uninitialized provider serves the declared default" + ); + flags.close().await; + } + + #[tokio::test] + async fn close_is_bounded_when_launchdarkly_hangs() { + let _already_installed = rustls::crypto::ring::default_provider().install_default(); + // A proxy that accepts connections and never answers: every SDK + // request, the final analytics flush included, hangs. + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let address = listener.local_addr().expect("address"); + std::thread::spawn(move || { + let mut held = Vec::new(); + for stream in listener.incoming().flatten() { + held.push(stream); + } + }); + let provider = parse(&[ + (PROVIDER_ENV, "launchdarkly"), + (SDK_KEY_ENV, "sdk-key"), + (RELAY_PROXY_ENV, &format!("http://{address}")), + ]) + .expect("valid configuration"); + let flags = FeatureFlags::build(provider, Duration::from_millis(50)) + .await + .expect("a hanging provider must not fail startup"); + // An evaluation queues an analytics event for the final flush. + let flag = buzz_feature_flags::IntegerFlag::new("buzz.relay.concurrency.auth", 0); + flags + .evaluator() + .evaluate_int(flag, &buzz_feature_flags::EvaluationContext::for_process()); + + let started = std::time::Instant::now(); + flags.close_within(Duration::from_millis(200)).await; + assert!( + started.elapsed() < Duration::from_secs(2), + "close returns at its deadline, not when the flush gives up: {:?}", + started.elapsed() + ); + } + + #[tokio::test] + async fn a_malformed_proxy_url_fails_startup() { + let provider = parse(&[ + (PROVIDER_ENV, "launchdarkly"), + (SDK_KEY_ENV, "sdk-key"), + (RELAY_PROXY_ENV, "ld-proxy.example.org"), + ]) + .expect("parses"); + let Err(error) = FeatureFlags::build(provider, Duration::from_millis(50)).await else { + panic!("a relative proxy URL must fail startup"); + }; + assert!( + error.to_string().contains("LaunchDarkly configuration"), + "{error}" + ); + } +} diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 1eb45dfb5d..ee8769876e 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -511,6 +511,10 @@ pub fn extract_auth_tag_json(event: &nostr::Event) -> Option { serde_json::to_string(first.as_slice()).ok() } +/// How long a verified AUTH waits for an [`AppState::auth_gate`] permit before +/// it is shed. Well inside every client's own AUTH timeout. +const AUTH_PERMIT_WAIT: std::time::Duration = std::time::Duration::from_secs(2); + /// Handle a NIP-42 AUTH message: verify the challenge response and transition /// the connection to authenticated state. /// @@ -579,6 +583,20 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: Ok(mut auth_ctx) => { let pubkey = auth_ctx.pubkey; let attempt = AuthAttempt::nip42(&conn, pubkey, true); + // Concurrency cap (`buzz.relay.concurrency.auth`, unbounded unless set). + // The signature is proven, so only real logins queue for the + // database checks below, and the permit is held through them. A + // shed login gets a bare close, as an auth timeout does: clients + // reconnect from that on their jittered backoff, while an `OK + // false` parks some clients and retries others in lockstep. + let Some(_concurrency_permit) = state.auth_gate.admit(AUTH_PERMIT_WAIT).await else { + metrics::counter!("buzz_auth_failures_total", "reason" => "concurrency_limit") + .increment(1); + if attempt.reject(&conn, AuthOutcome::ConcurrencyLimit) { + conn.cancel.cancel(); + } + return; + }; conn.community_control.prove_nip_fi_line(pubkey); let shadow = conn.community_control.nip_fi_shadow(); @@ -1084,6 +1102,95 @@ mod tests { .unwrap_or_default() } + /// The AUTH cap sheds a verified login with a bare close once its bounded + /// wait runs out; a free permit lets the next login through to its + /// database checks. + #[tokio::test] + async fn auth_cap_sheds_a_verified_login_with_a_bare_close() { + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _recorder_guard = metrics::set_default_local_recorder(&recorder); + let state = auth_test_state().await; + assert_eq!(state.auth_gate.set_limit(1), 1); + let held = state + .auth_gate + .admit(super::AUTH_PERMIT_WAIT) + .await + .expect("the only permit"); + + let mut shed = AuthHarness::new(false); + let started = std::time::Instant::now(); + shed.run(shed.auth_event(), std::sync::Arc::clone(&state)) + .await; + assert!( + started.elapsed() >= super::AUTH_PERMIT_WAIT, + "a login waits for a permit before it is shed" + ); + assert!(matches!(shed.conn.auth_state_snapshot(), AuthState::Failed)); + assert!( + shed.send_rx.try_recv().is_err(), + "no OK on the data channel" + ); + assert!( + shed.ctrl_rx.try_recv().is_err(), + "no OK on the ctrl channel" + ); + assert_eq!( + shed.wire_frames().await, + vec![WsMessage::Close(None)], + "a shed login sees only a bare close, as on an auth timeout" + ); + + drop(held); + let admitted = AuthHarness::new(false); + admitted + .run(admitted.auth_event(), std::sync::Arc::clone(&state)) + .await; + // Every AUTH records exactly one outcome. The admitted login's is the + // stub database's failure, so it got past the gate to its checks. + let mut outcomes: Vec<(String, u64)> = snapshotter + .snapshot() + .into_vec() + .into_iter() + .filter(|(key, ..)| key.key().name() == "buzz_auth_outcomes_total") + .map(|(key, _, _, value)| { + let outcome = key + .key() + .labels() + .find(|label| label.key() == "outcome") + .map(|label| label.value().to_owned()) + .unwrap_or_default(); + let DebugValue::Counter(count) = value else { + panic!("buzz_auth_outcomes_total must be a counter"); + }; + (outcome, count) + }) + .filter(|(_, count)| *count > 0) + .collect(); + outcomes.sort(); + assert_eq!(outcomes.len(), 2, "one outcome per login: {outcomes:?}"); + assert!( + outcomes + .iter() + .any(|(outcome, count)| outcome == "concurrency_limit" && *count == 1), + "the first login is shed: {outcomes:?}" + ); + assert!( + outcomes + .iter() + .any(|(outcome, count)| outcome != "concurrency_limit" && *count == 1), + "the second login, with a free permit, passes the gate: {outcomes:?}" + ); + assert!( + state + .auth_gate + .admit(std::time::Duration::from_millis(10)) + .await + .is_some(), + "the admitted login returns its permit" + ); + } + fn pending(challenge: &str) -> AuthState { AuthState::Pending { challenge: challenge.to_owned(), diff --git a/crates/buzz-relay/src/handlers/community_provisioning.rs b/crates/buzz-relay/src/handlers/community_provisioning.rs index 377783abec..e99f50b8f2 100644 --- a/crates/buzz-relay/src/handlers/community_provisioning.rs +++ b/crates/buzz-relay/src/handlers/community_provisioning.rs @@ -48,6 +48,9 @@ pub struct ProvisionCommunityRequest { /// host instead of converging or rotating ownership. #[serde(default)] pub create_only: bool, + /// Initial policy for an atomic create only; absent/null preserves legacy behavior. + #[serde(default)] + pub initial_requires_nip_fi: Option, } /// JSON response from `POST /operator/communities`. @@ -63,6 +66,9 @@ pub struct ProvisionCommunityResponse { /// Echoes the validated owner pubkey when an owner bootstrap/rotation ran. #[serde(skip_serializing_if = "Option::is_none")] pub owner_pubkey: Option, + /// Current stored policy, present for the create-only writer. + #[serde(skip_serializing_if = "Option::is_none")] + pub requires_nip_fi: Option, } pub(crate) fn validate_pubkey_hex(value: &str) -> Option { @@ -253,6 +259,12 @@ pub async fn provision_community( return Err("actor not authorized: not a relay operator".to_string()); } + if request.initial_requires_nip_fi.is_some() && !request.create_only { + return Err( + "initial_policy_requires_create_only: initial_requires_nip_fi requires create_only" + .to_owned(), + ); + } validate_host(&request.host)?; let nip_fi_policy = state.config.nip_fi_provision_binding.as_ref(); // Unbound either way: `issuers_for_new_host` binds nothing on a reserved host. @@ -278,14 +290,18 @@ pub async fn provision_community( })?; let write = state .db - .create_community_with_owner_and_nip_fi_issuers( + .create_community_with_owner_and_nip_fi_policy( &request.host, owner_hex, nip_fi_issuers, + request.initial_requires_nip_fi.unwrap_or(false), &state.relay_keypair, ) .await - .map_err(|e| format!("failed to create community: {e}"))?; + .map_err(|e| match e { + buzz_db::DbError::NipFiBindingRequired => format!("nip_fi_binding_required: {e}"), + _ => format!("failed to create community: {e}"), + })?; let record = match write.outcome { buzz_db::CreateCommunityWithOwnerResult::Created(record) => record, buzz_db::CreateCommunityWithOwnerResult::HostExists => { @@ -301,7 +317,9 @@ pub async fn provision_community( community = %record.id, host = %record.host, owner = %owner_hex, - "community created via operator endpoint" + created = record.created, + requires_nip_fi = record.requires_nip_fi, + "community provisioned via operator endpoint" ); record_binding_creation( state.nip_fi_resolver.as_deref(), @@ -313,8 +331,9 @@ pub async fn provision_community( return Ok(ProvisionCommunityResponse { community_id: record.id.to_string(), host: record.host, - status: "created", + status: if record.created { "created" } else { "existed" }, owner_pubkey: initial_owner, + requires_nip_fi: Some(record.requires_nip_fi), }); } @@ -369,6 +388,7 @@ pub async fn provision_community( host: record.host, status: if record.created { "created" } else { "existed" }, owner_pubkey: initial_owner, + requires_nip_fi: None, }) } diff --git a/crates/buzz-relay/src/handlers/system_channel_postgres_tests.rs b/crates/buzz-relay/src/handlers/system_channel_postgres_tests.rs index 530f6db3bb..d04f0a337d 100644 --- a/crates/buzz-relay/src/handlers/system_channel_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/system_channel_postgres_tests.rs @@ -1550,6 +1550,7 @@ async fn fan_out_from_another_pod(f: &Fixture, channel: Uuid, name: &str) -> Str community_id: f.tenant.community(), topic: buzz_pubsub::EventTopic::Channel(channel), event, + received_at: tokio::time::Instant::now(), }, ) .await; diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 9a580935c8..3f6164ddb1 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -28,6 +28,8 @@ pub(crate) mod nip_fi_upgrade; pub mod api; /// WebSocket audio relay for huddle voice channels. pub mod audio; +/// Operator-tunable concurrency caps, re-read from feature flags at runtime. +pub mod concurrency_limits; /// Relay configuration from environment variables. pub mod config; /// Runtime conformance harness — abstract trace emission at the @@ -39,6 +41,8 @@ pub mod conformance; pub mod connection; /// Relay error types. pub mod error; +/// The feature-flag provider, chosen once at startup. +pub mod feature_flags; /// WebSocket message handlers for NIP-01 client commands. pub mod handlers; /// Stateless HMAC-signed relay invite tokens (mint/verify). diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index ffda48bd59..d979020bed 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -341,6 +341,9 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { "Prometheus metrics exporter started" ); + // Before the database: a provider misconfiguration fails startup at once. + let feature_flags = Arc::new(buzz_relay::feature_flags::FeatureFlags::from_env().await?); + let db_config = DbConfig { push_enabled: config.push_enabled, database_url: config.database_url.clone(), @@ -746,6 +749,16 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { }, ); let state = Arc::new(app_state); + // Concurrency caps follow their flags from the first request: apply them + // once before serving, then keep re-reading them. + let concurrency_gates = buzz_relay::concurrency_limits::relay_gates(&state); + buzz_relay::concurrency_limits::apply(&feature_flags, &concurrency_gates); + let concurrency_limits_cancel = CancellationToken::new(); + tokio::spawn(buzz_relay::concurrency_limits::run( + Arc::clone(&feature_flags), + concurrency_gates, + concurrency_limits_cancel.clone(), + )); if let Some(tenant) = &deployment_community { buzz_relay::handlers::side_effects::fan_out_nip43_membership_list( tenant, @@ -1540,6 +1553,7 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { } } state.community_revalidator_cancel.cancel(); + concurrency_limits_cancel.cancel(); state.dependency_sampler_cancel.cancel(); state.dependency_completion_publisher_cancel.cancel(); @@ -1550,6 +1564,9 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { .drain(std::time::Duration::from_secs(5)) .await; + // Last of the drains, and bounded: LaunchDarkly's final analytics flush. + feature_flags.close().await; + // Flush pending OTEL spans before exit. if let telemetry::TracerInit::Enabled(tp) = tracer_init { if let Err(e) = tp.shutdown() { diff --git a/crates/buzz-relay/src/metrics.rs b/crates/buzz-relay/src/metrics.rs index eebc313991..65f12599f2 100644 --- a/crates/buzz-relay/src/metrics.rs +++ b/crates/buzz-relay/src/metrics.rs @@ -43,13 +43,15 @@ pub(crate) enum AuthOutcome { NotRelayMember, /// NIP-FI key pairing mismatch: the NIP-42 key differs from the asserted key. PairingMismatch, + /// Shed by the AUTH concurrency cap (`buzz.relay.concurrency.auth`). + ConcurrencyLimit, Timeout, Disconnect, Shutdown, } impl AuthOutcome { - pub(crate) const ALL: [Self; 12] = [ + pub(crate) const ALL: [Self; 13] = [ Self::Success, Self::Invalid, Self::Banned, @@ -59,6 +61,7 @@ impl AuthOutcome { Self::RelayMembershipCheckError, Self::NotRelayMember, Self::PairingMismatch, + Self::ConcurrencyLimit, Self::Timeout, Self::Disconnect, Self::Shutdown, @@ -75,6 +78,7 @@ impl AuthOutcome { Self::RelayMembershipCheckError => "relay_membership_check_error", Self::NotRelayMember => "not_relay_member", Self::PairingMismatch => "pairing_mismatch", + Self::ConcurrencyLimit => "concurrency_limit", Self::Timeout => "timeout", Self::Disconnect => "disconnect", Self::Shutdown => "shutdown", diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index d021774c8a..d841a0c845 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -1475,6 +1475,11 @@ pub struct AppState { pub conn_semaphore: Arc, /// Semaphore limiting concurrent message handler tasks. pub handler_semaphore: Arc, + /// Cap on NIP-42 AUTH frames past signature verification and into their + /// database checks at once. Unbounded until an operator sets a limit + /// through the `buzz.relay.concurrency.auth` flag (see + /// [`crate::concurrency_limits`]). + pub auth_gate: buzz_db::ConcurrencyGate, /// Semaphore limiting concurrent git subprocess operations across /// the whole relay. Bounds resource use; **not** writer /// serialization — that's the CAS at the manifest pointer (spec @@ -1834,6 +1839,7 @@ impl AppState { community_disconnect_publish_attempts: Arc::new(AtomicU64::new(0)), conn_semaphore: Arc::new(Semaphore::new(max_connections)), handler_semaphore: Arc::new(Semaphore::new(max_concurrent_handlers)), + auth_gate: buzz_db::ConcurrencyGate::unbounded(), git_semaphore: Arc::new(Semaphore::new(git_max_concurrent_ops)), media_upload_semaphore: Arc::new(Semaphore::new(media_max_concurrent_uploads)), workflow_engine, diff --git a/crates/buzz-test-client/src/loadgen/identity.rs b/crates/buzz-test-client/src/loadgen/identity.rs index 4ed86986bb..dd181d792e 100644 --- a/crates/buzz-test-client/src/loadgen/identity.rs +++ b/crates/buzz-test-client/src/loadgen/identity.rs @@ -16,6 +16,9 @@ pub enum Role { /// Posts only hot-thread roots, so `run` finds them among its newest /// posts however many other roots a channel has. HotRoot, + /// Creates a block of channels after a community's first and signs their + /// seed writes; see `seed::channel_creator`. + Creator, } impl Role { @@ -26,6 +29,7 @@ impl Role { Role::Agent => "agent", Role::HotAuthor => "hot-author", Role::HotRoot => "hot-root", + Role::Creator => "creator", } } } diff --git a/crates/buzz-test-client/src/loadgen/run.rs b/crates/buzz-test-client/src/loadgen/run.rs index 436ecd99e2..607605a7b7 100644 --- a/crates/buzz-test-client/src/loadgen/run.rs +++ b/crates/buzz-test-client/src/loadgen/run.rs @@ -28,7 +28,9 @@ use super::desktop; use super::identity::{self, Role}; use super::net::{http_reason, Net}; use super::scenario::{AgentTurn, Community, HumanProfile, Members, Scenario, WideSplit}; -use super::seed::{find_roots, hot_root_keys, reply_tags, HOT_ROOT_PREFIX, ROOT_PREFIX}; +use super::seed::{ + channel_creator, find_roots, hot_root_keys, reply_tags, HOT_ROOT_PREFIX, ROOT_PREFIX, +}; use super::stats::{abort_reason, ops_json, Op, Pending, Stats}; /// Content prefix of timed messages: `lg:`. @@ -74,6 +76,9 @@ pub(super) struct Shared { pub(super) stop: watch::Receiver, /// Seeded thread roots per channel id, resolved lazily by whichever client needs them first. roots: Mutex>>>, + /// Index within its community of every channel id, to name the + /// [`channel_creator`] whose roots a lookup searches for. + channel_indices: HashMap, /// Channel members and human pubkeys, built once by the first desktop /// client that needs them. pub(super) directory: tokio::sync::OnceCell>, @@ -93,6 +98,15 @@ impl Shared { stats: Arc, stop: watch::Receiver, ) -> Self { + let channel_indices = (0..scenario.shape.communities) + .flat_map(|community| { + scenario + .channel_ids(community) + .into_iter() + .enumerate() + .map(|(index, id)| (id, index)) + }) + .collect(); Self { communities: scenario.communities(), scenario, @@ -100,6 +114,7 @@ impl Shared { stats, stop, roots: Mutex::new(HashMap::new()), + channel_indices, directory: tokio::sync::OnceCell::new(), } } @@ -562,9 +577,17 @@ pub(super) async fn roots( if let Some(found) = shared.roots.lock().await.get(channel) { return found.clone(); } - let owner = identity::keys(&shared.scenario.run_id, Role::Owner, community.index); + let index = shared.channel_indices.get(channel).copied().unwrap_or(0); + let author = channel_creator(&shared.scenario, community.index, index); let pending = shared.stats.begin(Op::HttpQuery); - let lookup = find_roots(&shared.net, community, reader, &owner, channel, ROOT_PREFIX); + let lookup = find_roots( + &shared.net, + community, + reader, + &author, + channel, + ROOT_PREFIX, + ); let result = match bound { None => lookup.await, Some(limit) => match bounded(shared, limit, lookup).await { @@ -3478,4 +3501,53 @@ hold_secs = 1 assert_eq!(settled["hot_reply"]["count"], 8, "{settled}"); assert_eq!(relay.posted.lock().unwrap().len(), 8, "{settled}"); } + + #[tokio::test] + async fn root_lookups_search_the_channel_creators_posts() { + let queries: Log = Default::default(); + let query_log = queries.clone(); + let addr = super::super::seed::tests::fake_http(move |_, _, body| { + let filter: Value = serde_json::from_slice(body).unwrap(); + query_log.lock().unwrap().push(filter[0].clone()); + (200, "[]".to_string()) + }) + .await; + let scenario = Scenario::parse(&format!( + r#" +run_id = "t" +[target] +ws_url = "ws://{addr}" +http_url = "http://{addr}" +community_host_template = "lg-{{run}}-{{i}}.example" +connect_addr = "{addr}" +[shape] +communities = 2 +channels_per_community = 17 +[population] +humans = 1 +agents = 0 +channels_per_member = 1 +[[ramp]] +users = 1.0 +hold_secs = 1 +"# + )) + .unwrap(); + let net = Net::new(&scenario).await.unwrap(); + let (_stop, stop) = watch::channel(false); + let shared = Shared::new(scenario, net, Arc::new(Stats::default()), stop); + let reader = Keys::generate(); + for (community, channel) in [(0, 0), (0, 9), (1, 16)] { + let id = identity::channel_id("t", community, channel); + let c = &shared.communities[community]; + roots(&shared, c, &reader, &id, None).await; + let author = channel_creator(&shared.scenario, community, channel); + let lookup = queries.lock().unwrap().pop().unwrap(); + assert_eq!(lookup["#h"], json!([id])); + assert_eq!(lookup["authors"], json!([author.public_key().to_hex()])); + } + let owner = identity::keys("t", Role::Owner, 1).public_key(); + assert_eq!(channel_creator(&shared.scenario, 1, 7).public_key(), owner); + assert_ne!(channel_creator(&shared.scenario, 1, 16).public_key(), owner); + } } diff --git a/crates/buzz-test-client/src/loadgen/seed.rs b/crates/buzz-test-client/src/loadgen/seed.rs index 20f12b9a06..7f81ac3f2e 100644 --- a/crates/buzz-test-client/src/loadgen/seed.rs +++ b/crates/buzz-test-client/src/loadgen/seed.rs @@ -1,8 +1,9 @@ //! Creates the scenario's fixture: communities, channels, history and deep threads. use std::collections::HashMap; +use std::future::Future; use std::sync::Arc; -use std::time::Instant; +use std::time::{Duration, Instant}; use futures_util::stream::{self, StreamExt}; use nostr::{Event, EventBuilder, JsonUtil, Keys, Kind, Tag}; @@ -12,7 +13,7 @@ use serde_json::json; use super::identity::{self, Role}; use super::net::{http_reason, truncate, Net}; use super::scenario::{Community, Member, Members, Scenario}; -use super::stats::{Op, Snapshot, Stats}; +use super::stats::{is_throttle, Op, Snapshot, Stats}; /// Content prefix of seeded thread roots; `run` finds roots by it. pub const ROOT_PREFIX: &str = "lg-root"; @@ -27,8 +28,77 @@ pub fn hot_root_keys(run_id: &str) -> Keys { /// Most people one kind:9000 may name, the relay's limit. const MAX_ADDS_PER_EVENT: usize = 100; +/// Channels each creator creates and seeds; see [`channel_creator`]. +pub const CHANNELS_PER_CREATOR: usize = 8; + +/// Creator of channel `channel` in community `community`: the community +/// owner for its first [`CHANNELS_PER_CREATOR`] channels, then one +/// [`Role::Creator`] identity per further block of that many. The creator +/// signs its channels' kind:9007 and kind:9000 events and their thread roots. +/// The relay limits HTTP calls per pubkey, so one signer for a community's +/// tens of thousands of channels is throttled for hours; blocks of 8 keep +/// those writes spread out, and [`paced`] absorbs what still bunches up. +pub fn channel_creator(scenario: &Scenario, community: usize, channel: usize) -> Keys { + let run_id = &scenario.run_id; + let block = channel / CHANNELS_PER_CREATOR; + if block == 0 { + return identity::keys(run_id, Role::Owner, community); + } + let blocks = scenario + .shape + .channels_per_community + .div_ceil(CHANNELS_PER_CREATOR); + identity::keys(run_id, Role::Creator, community * blocks + block) +} + +/// Most times one seed write is retried after the relay throttles its signer. +const MAX_THROTTLED_RETRIES: usize = 10; + +/// Runs `write` until it succeeds or fails other than by the relay's +/// per-pubkey quota. A throttled write waits out the window the relay names +/// (`retry in s`) and tries again, at most [`MAX_THROTTLED_RETRIES`] times, +/// so a signer whose writes bunch up slows down instead of failing the seed. +/// With `stats`, each retried rejection counts as a throttled seed write. +/// Returns how long the accepted attempt took, without the waits before it. +async fn paced(stats: Option<&Stats>, mut write: F) -> anyhow::Result +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let mut retries = 0; + loop { + let t0 = Instant::now(); + match write().await { + Ok(()) => return Ok(t0.elapsed()), + Err(e) if retries < MAX_THROTTLED_RETRIES && is_throttle(&e.to_string()) => { + let reason = e.to_string(); + if let Some(stats) = stats { + stats.err(Op::Seed, &reason); + } + retries += 1; + tokio::time::sleep(retry_after(&reason)).await; + } + Err(e) => return Err(e), + } + } +} + +/// How long to wait after a quota rejection: the `retry in s` the relay +/// names, capped at a minute, plus a quarter second for clock skew. +fn retry_after(reason: &str) -> Duration { + let secs = reason + .split("retry in ") + .nth(1) + .and_then(|rest| rest.split('s').next()) + .and_then(|n| n.trim().parse::().ok()) + .unwrap_or(1) + .min(60); + Duration::from_secs(secs) + Duration::from_millis(250) +} + /// Seeds every community. Idempotent for communities this run's fixture -/// owner owns, for channels and for memberships; history and threads are +/// owner owns, for channels and for memberships (each signed by its +/// [`channel_creator`]); history and threads are /// appended on every call. Fails unless every planned post was accepted and, /// with private channels, every member was added. pub async fn seed(scenario: &Scenario, net: &Net, stats: &Arc) -> anyhow::Result<()> { @@ -41,9 +111,9 @@ pub async fn seed(scenario: &Scenario, net: &Net, stats: &Arc) -> anyhow: if let Some(host) = &community.host { provision(scenario, net, host, &owner).await?; } - create_channels(scenario, net, &community, &owner).await?; + create_channels(scenario, net, &community).await?; if let Some(members) = &members { - let ensured = add_members(scenario, net, &community, &owner, members).await?; + let ensured = add_members(scenario, net, &community, members).await?; eprintln!( "seed: community {} memberships ensured ({ensured} people)", community.index @@ -122,10 +192,9 @@ async fn post_all( previous = None; continue; }; - let t0 = Instant::now(); - match net.submit(&keys, community, &event, None).await { - Ok(()) => { - stats.ok(Op::Seed, t0.elapsed()); + match paced(Some(stats), || net.submit(&keys, community, &event, None)).await { + Ok(latency) => { + stats.ok(Op::Seed, latency); let id = event.id.to_hex(); accepted.push(((post.community, post.channel, post.seq), id.clone())); previous = Some(id); @@ -213,11 +282,11 @@ async fn provision_host( Ok(()) } +/// Creates every channel of `community`, each signed by its [`channel_creator`]. async fn create_channels( scenario: &Scenario, net: &Net, community: &Community, - owner: &Keys, ) -> anyhow::Result<()> { let visibility = if scenario.shape.private_channels { "private" @@ -227,6 +296,7 @@ async fn create_channels( let results: Vec> = stream::iter(scenario.channel_ids(community.index)) .enumerate() .map(|(i, channel)| async move { + let creator = channel_creator(scenario, community.index, i); let event = EventBuilder::new(Kind::Custom(9007), "") .tags([ tag(["h", &channel]), @@ -234,8 +304,8 @@ async fn create_channels( tag(["channel_type", "stream"]), tag(["visibility", visibility]), ]) - .sign_with_keys(owner)?; - match net.submit(owner, community, &event, None).await { + .sign_with_keys(&creator)?; + match paced(None, || net.submit(&creator, community, &event, None)).await { Err(e) if !e.to_string().contains("already exists") => Err(e), _ => Ok(()), } @@ -247,7 +317,7 @@ async fn create_channels( } /// Adds every member of `community`'s channels with kind:9000 events from -/// the channel owner, at most [`MAX_ADDS_PER_EVENT`] people each and +/// the channel's [`channel_creator`], at most [`MAX_ADDS_PER_EVENT`] people each and /// `seed_concurrency` at a time. Channel 0 of community 0 also gets the /// hot-thread root author and authors. Adding a current member at its role /// changes nothing, so re-running is safe. Returns how many people were @@ -256,7 +326,6 @@ async fn add_members( scenario: &Scenario, net: &Net, community: &Community, - owner: &Keys, members: &Members, ) -> anyhow::Result { let run_id = &scenario.run_id; @@ -274,8 +343,10 @@ async fn add_members( ); } let id = identity::channel_id(run_id, community.index, channel); + let creator = channel_creator(scenario, community.index, channel); for chunk in people.chunks(MAX_ADDS_PER_EVENT) { adds.push(( + creator.clone(), id.clone(), chunk.iter().map(|k| k.public_key().to_hex()).collect(), )); @@ -283,14 +354,16 @@ async fn add_members( } let named: usize = adds .iter() - .map(|(_, p): &(String, Vec)| p.len()) + .map(|(_, _, p): &(Keys, String, Vec)| p.len()) .sum(); let failures: Vec = stream::iter(adds) - .map(|(channel, people)| async move { - put_users(net, community, owner, &channel, &people) - .await - .err() - .map(|e| format!("{channel}: {e}")) + .map(|(creator, channel, people)| async move { + paced(None, || { + put_users(net, community, &creator, &channel, &people) + }) + .await + .err() + .map(|e| format!("{channel}: {e}")) }) .buffer_unordered(scenario.shape.seed_concurrency) .filter_map(|failure| async move { failure }) @@ -438,7 +511,7 @@ impl Post { let (keys, content) = match self.kind { PostKind::History => (author, format!("lg-history {}", self.seq)), PostKind::Root => ( - identity::keys(&scenario.run_id, Role::Owner, self.community), + channel_creator(scenario, self.community, self.channel), format!("{ROOT_PREFIX} {}", self.seq), ), PostKind::Reply { root } => { @@ -476,20 +549,20 @@ pub fn reply_tags(root: &str, parent: &str) -> [Tag; 2] { ] } -/// Ids of `owner`'s seeded roots in `channel` whose content starts with +/// Ids of `author`'s seeded roots in `channel` whose content starts with /// `prefix`, oldest first, among its 100 newest posts there. `run` replies /// into these; a sample of roots is enough load. pub async fn find_roots( net: &Net, community: &Community, reader: &Keys, - owner: &Keys, + author: &Keys, channel: &str, prefix: &str, ) -> anyhow::Result> { let filter = json!([{ "kinds": [9], - "authors": [owner.public_key().to_hex()], + "authors": [author.public_key().to_hex()], "#h": [channel], "limit": 100, }]); @@ -520,7 +593,10 @@ pub async fn find_roots( impl Post { /// Rotates authors across humans to stay under per-pubkey rate limits: /// across all humans for open channels, across the channel's own human - /// members for private ones, falling back to the owner when it has none. + /// members for private ones, falling back to the channel's + /// [`channel_creator`] when it has none. Only humans look roots up, so in + /// their channels the creator signs nothing but roots, and no volume of + /// history or replies crowds roots out of that lookup's newest 100. fn author(&self, scenario: &Scenario, members: Option<&Members>) -> Keys { let run_id = &scenario.run_id; let Some(members) = members else { @@ -534,7 +610,7 @@ impl Post { .collect(); match humans.get(self.seq % humans.len().max(1)) { Some(m) => identity::keys(run_id, Role::Human, m.index), - None => identity::keys(run_id, Role::Owner, self.community), + None => channel_creator(scenario, self.community, self.channel), } } } @@ -546,6 +622,7 @@ fn tag(parts: [&str; N]) -> Tag { #[cfg(test)] pub(super) mod tests { use super::*; + use std::collections::HashSet; use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; /// Serves HTTP/1.1 on loopback. `handler(method, target, body)` returns @@ -794,8 +871,10 @@ ws_url = "ws://{addr}" http_url = "http://{addr}" [shape] communities = 1 -channels_per_community = 3 +channels_per_community = 20 history_messages_per_channel = 3 +thread_roots_per_channel = 1 +thread_depth = 3 private_channels = true wide_channels = 1 [population] @@ -820,10 +899,26 @@ hold_secs = 1 outcome.expect("seed passes"); let kind = |e: &Event| e.kind.as_u16(); let creates: Vec<&Event> = events.iter().filter(|e| kind(e) == 9007).collect(); - assert_eq!(creates.len(), 3); + assert_eq!(creates.len(), 20); assert!(creates .iter() .all(|e| values(e, "visibility") == ["private"])); + let index = |e: &Event| { + let [channel] = values(e, "h").try_into().unwrap(); + (0..20) + .find(|c| identity::channel_id("t", 0, *c) == channel) + .unwrap() + }; + let creator = |c| channel_creator(&scenario, 0, c).public_key(); + // The owner creates channels 0-7; each further block of 8 has its own creator. + for create in &creates { + assert_eq!(create.pubkey, creator(index(create))); + } + let owner = identity::keys("t", Role::Owner, 0).public_key(); + assert_eq!(creator(7), owner); + assert_ne!(creator(8), owner); + assert_eq!(creator(8), creator(15)); + assert_ne!(creator(16), creator(8)); let first_put = events.iter().position(|e| kind(e) == 9000).unwrap(); let last_put = events.iter().rposition(|e| kind(e) == 9000).unwrap(); let first_post = events.iter().position(|e| kind(e) == 9).unwrap(); @@ -831,10 +926,9 @@ hold_secs = 1 assert!(last_create < first_put && last_put < first_post); let members = scenario.channel_members(); - let owner = identity::keys("t", Role::Owner, 0); let mut added: HashMap> = HashMap::new(); for put in events.iter().filter(|e| kind(e) == 9000) { - assert_eq!(put.pubkey, owner.public_key()); + assert_eq!(put.pubkey, creator(index(put))); let people = values(put, "p"); assert!(!people.is_empty() && people.len() <= MAX_ADDS_PER_EVENT); let [channel] = values(put, "h").try_into().unwrap(); @@ -863,13 +957,16 @@ hold_secs = 1 } assert!(added.is_empty()); - // Every post is authored by a human member of its channel. + // The creator signs each channel's roots; every other post is by a + // human member of its channel, so none crowds a root out of `run`'s + // newest-100 lookup. for post in events.iter().filter(|e| kind(e) == 9) { - let [channel] = values(post, "h").try_into().unwrap(); - let c = (0..3) - .find(|c| identity::channel_id("t", 0, *c) == channel) - .unwrap(); + let c = index(post); let author = post.pubkey.to_hex(); + if post.content.starts_with(ROOT_PREFIX) { + assert_eq!(post.pubkey, creator(c)); + continue; + } let member = members[0][c].iter().any(|m| { m.role == Role::Human && identity::keys("t", Role::Human, m.index) @@ -878,10 +975,170 @@ hold_secs = 1 == author }); let hot_root = author == hot_root_keys("t").public_key().to_hex(); - assert!(member || hot_root, "{author} posted in channel {c}"); + let hot_author = (0..3).any(|i| { + identity::keys("t", Role::HotAuthor, i) + .public_key() + .to_hex() + == author + }); + assert!( + member || hot_root || hot_author, + "{author} posted in channel {c}" + ); } } + /// One community of many private channels, wide channels and a tier of + /// humans in most channels, at 2x. + const MANY_CHANNELS: &str = r#" +run_id = "t" +[target] +ws_url = "ws://{addr}" +http_url = "http://{addr}" +[shape] +communities = 1 +channels_per_community = 400 +history_messages_per_channel = 10 +thread_roots_per_channel = 1 +thread_depth = 5 +seed_concurrency = 64 +private_channels = true +wide_channels = 3 +[population] +humans = 300 +agents = 30 +channels_per_member = 3 +wide_member_fraction = 0.75 +tiers = [{ fraction = 0.1, channels = 280 }] +[[ramp]] +users = 2.0 +hold_secs = 1 +"#; + + /// The relay's default HTTP calls a minute for one pubkey. + const HTTP_CALLS_PER_MIN: usize = 300; + + #[tokio::test] + async fn no_channel_creator_signs_more_seed_writes_than_one_minute_of_its_quota() { + let (outcome, scenario, events) = seed_logged(MANY_CHANNELS, "").await; + outcome.expect("seed passes"); + let creators: HashSet = (0..400) + .map(|c| channel_creator(&scenario, 0, c).public_key().to_hex()) + .collect(); + assert_eq!(creators.len(), 400 / CHANNELS_PER_CREATOR); + let mut writes: HashMap = HashMap::new(); + for event in events + .iter() + .filter(|e| creators.contains(&e.pubkey.to_hex())) + { + *writes.entry(event.pubkey.to_hex()).or_default() += 1; + } + // Creators sign every create, member add and root. Under one minute's + // quota in total, a creator is never throttled, however its writes + // bunch up; humans' posts are paced instead. + let (busiest, most) = writes.iter().max_by_key(|(_, n)| **n).unwrap(); + assert!( + *most < HTTP_CALLS_PER_MIN, + "{busiest} signed {most} of {} seed writes", + events.len() + ); + } + + /// When each write was submitted, keyed by what it says: a kind:9000 is + /// re-signed on each try, so its id changes. + type Tries = HashMap>; + + /// Seeds `scenario` against a relay that answers each write's first + /// `throttles` submissions with the relay's HTTP 429, naming a + /// `retry_secs` window. Returns the outcome and every write's submissions. + async fn seed_throttled( + scenario: &str, + throttles: usize, + retry_secs: u64, + ) -> (anyhow::Result<()>, Tries, Arc) { + let tries: Arc> = Default::default(); + let seen = tries.clone(); + let addr = fake_http(move |_, _, body| { + let event: Event = serde_json::from_slice(body).unwrap(); + let write = format!("{} {} {:?}", event.pubkey, event.content, event.tags); + let mut seen = seen.lock().unwrap(); + let at = seen.entry(write).or_default(); + at.push(Instant::now()); + if at.len() <= throttles { + let error = format!("rate-limited: quota exceeded; retry in {retry_secs}s"); + return (429, json!({ "error": error }).to_string()); + } + (200, json!({"accepted": true, "message": ""}).to_string()) + }) + .await; + let scenario = Scenario::parse(&scenario.replace("{addr}", &addr)).unwrap(); + let net = Net::new(&scenario).await.unwrap(); + let stats = Arc::new(Stats::default()); + let outcome = seed(&scenario, &net, &stats).await; + let tries = tries.lock().unwrap().clone(); + (outcome, tries, stats) + } + + #[tokio::test] + async fn throttled_writes_wait_out_the_quota_and_a_lasting_quota_fails_the_seed() { + let (outcome, tries, _) = seed_throttled(PRIVATE, 2, 0).await; + outcome.expect("throttled writes are retried"); + assert!(tries.values().all(|at| at.len() == 3), "{tries:?}"); + + let (outcome, tries, _) = seed_throttled(PRIVATE, usize::MAX, 0).await; + let err = outcome.expect_err("a quota that never clears fails the seed"); + assert!(err.to_string().contains("quota exceeded"), "{err}"); + let most = MAX_THROTTLED_RETRIES + 1; + assert!(tries.values().all(|at| at.len() == most), "{tries:?}"); + + // A retry waits out the window the relay names, and the post's + // rejection counts as throttled without its wait counting as latency; + // one channel and one post keep the wait short. + let one = r#" +run_id = "t" +[target] +ws_url = "ws://{addr}" +http_url = "http://{addr}" +[shape] +communities = 1 +channels_per_community = 1 +history_messages_per_channel = 1 +[population] +humans = 1 +agents = 0 +channels_per_member = 1 +[[ramp]] +users = 1.0 +hold_secs = 1 +"#; + let (outcome, tries, stats) = seed_throttled(one, 1, 1).await; + outcome.expect("throttled writes are retried"); + assert_eq!(tries.len(), 2, "{tries:?}"); + for at in tries.values() { + let [first, retry] = at[..] else { + panic!("{at:?}"); + }; + assert!(retry - first >= Duration::from_secs(1), "{at:?}"); + } + let posts = stats.snapshot().since(&Snapshot::default()); + let (_, posts) = posts.iter().find(|(op, _)| *op == Op::Seed).unwrap(); + assert_eq!((posts.count, posts.throttled, posts.errors), (1, 1, 0)); + assert!(posts.max_ms < 1000.0, "{posts:?}"); + } + + #[test] + fn retry_after_waits_the_named_window_capped_at_a_minute() { + let quarter = Duration::from_millis(250); + let wait = |reason: &str| retry_after(reason) - quarter; + let body = |n: &str| { + format!("HTTP 429 Too Many Requests: {{\"error\":\"rate-limited: quota exceeded; retry in {n}s\"}}") + }; + assert_eq!(wait(&body("7")), Duration::from_secs(7)); + assert_eq!(wait(&body("0")), Duration::ZERO); + assert_eq!(wait(&body("3600")), Duration::from_secs(60)); + assert_eq!(wait("HTTP 429 Too Many Requests: "), Duration::from_secs(1)); + } + #[tokio::test] async fn refused_members_fail_the_seed_and_open_channels_add_nobody() { // The relay accepts an event that refuses some of the people it names. @@ -1059,8 +1316,7 @@ hold_secs = 1 .unwrap(); let net = Net::new(&scenario).await.unwrap(); let community = &scenario.communities()[0]; - let owner = identity::keys("t", Role::Owner, 0); - create_channels(&scenario, &net, community, &owner).await + create_channels(&scenario, &net, community).await }; outcome("duplicate: channel already exists") .await diff --git a/crates/buzz-test-client/src/loadgen/stats.rs b/crates/buzz-test-client/src/loadgen/stats.rs index 1f469a437b..ab7ac30cf4 100644 --- a/crates/buzz-test-client/src/loadgen/stats.rs +++ b/crates/buzz-test-client/src/loadgen/stats.rs @@ -316,7 +316,7 @@ const QUOTA_EXCEEDED: &str = "rate-limited: quota exceeded"; /// Quota rejections only: an HTTP 429 status or the relay's quota text. Other /// `rate-limited:` rejections, such as `shared admission unavailable` when the /// relay's admission store is down, are relay failures and count as errors. -fn is_throttle(reason: &str) -> bool { +pub(super) fn is_throttle(reason: &str) -> bool { reason.starts_with(HTTP_TOO_MANY_REQUESTS) || reason.contains(QUOTA_EXCEEDED) } diff --git a/docs/operator-concurrency-caps.md b/docs/operator-concurrency-caps.md new file mode 100644 index 0000000000..702bf74ecd --- /dev/null +++ b/docs/operator-concurrency-caps.md @@ -0,0 +1,60 @@ +# Relay Concurrency Caps + +Two per-pod caps can be tightened while the relay runs, through integer +feature flags evaluated in the process context (see +`crates/buzz-relay/src/concurrency_limits.rs`): + +| Flag | Caps | Ceiling | +|------|------|---------| +| `buzz.relay.concurrency.history-reads` | subscription-history reads holding a reader-pool connection | the reader-pool share reserved for history | +| `buzz.relay.concurrency.auth` | verified NIP-42 AUTH frames in their database checks | none | + +A positive value sets the cap, clamped to the ceiling. Zero, a negative value +or no value leaves the cap at its ceiling. A flag can only tighten a cap. +Each pod re-reads the flags every 5 s. + +Past the history-read cap, a read waits up to 1 s and is then refused as +retryable. Past the AUTH cap, a login waits up to 2 s and then gets a bare +close, and the client reconnects on its own backoff. + +## Tightening a cap + +1. Set the flag to a positive value for the deployment's environment. +2. Within about 5 s, check `buzz_concurrency_limit{gate}` on every pod + (`history_reads` or `auth`). It shows the applied limit, with `0` meaning + no limit. +3. Watch the sheds: `buzz_auth_outcomes_total{outcome="concurrency_limit"}` + for AUTH. + +To release, set the flag back to `0`. + +## When a tightened cap loosens + +A cap returns to its ceiling, without an error, when: + +- **The flag value cannot be read as an integer.** A fractional, string or + otherwise mistyped value evaluates to the default `0`. Change values only + among the flag's defined numeric variations. +- **The flag is deleted or turned off with an off variation of `0`.** +- **A pod starts while the flag provider is unreachable.** The relay waits up + to 3 s for flag data, then serves defaults until the provider connects. A + pod restarted during a provider outage therefore comes up at the ceiling, + even while the cap is tightened for the pods that were already running. + `buzz_feature_flags_ready{provider} == 0` marks such a pod. + +A pod that has already received flag data keeps its last-known values through +a later provider outage, so a running pod's cap does not loosen when the +connection drops. + +During an incident in which the provider may be down, confirm +`buzz_concurrency_limit` on each pod after every restart or scale-up, not just +after the flag change. + +## Health signals + +- `buzz_feature_flags_ready{provider}`: `1` once the provider has delivered + data (always `1` for the `static` and `environment` providers). +- `buzz_concurrency_limit_refresh_panics_total`: a refresh that panicked. The + next refresh retries; a rising count means the caps are not following the + flags. +- Shutdown waits at most 2 s for the provider's final analytics flush. diff --git a/docs/operator-nip-fi-bindings.md b/docs/operator-nip-fi-bindings.md index 2fc21a1b50..744154c56c 100644 --- a/docs/operator-nip-fi-bindings.md +++ b/docs/operator-nip-fi-bindings.md @@ -69,6 +69,36 @@ they end at their own session deadline. Alert on `time() - buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds > 120` and on `absent(buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds)`. +## Initial policy on operator creation + +A trusted NIP-98 operator may send `initial_requires_nip_fi: true` with +`create_only: true` to `POST /operator/communities`. The community, owner, +issuer bindings and required policy (generation 1) commit in one transaction. +Omitted, null or false creates an optional community at generation 0. A non-null +policy field without `create_only: true` returns 400 +`initial_policy_requires_create_only`. + +Required insertion needs an effective provision binding for the host; otherwise +400 `nip_fi_binding_required` leaves no row. Reserved hosts retain the existing +`reserved_host` refusal. The successful response includes the stored +`requires_nip_fi` and `status: "created"` for insertion or `"existed"` for a +same-owner replay. A replay changes neither policy nor bindings, including after +an operator disable or a change in issuer configuration. A different owner +retains the existing conflict. Legacy ensure and deployment bootstrap remain +optional and do not accept an explicit initial policy. + +This policy is independent of `BUZZ_NIP_FI_UPGRADES_ENABLED`. Mode `off` stores +it but bypasses enforcement. The production creation paths are operator +create-only, legacy operator ensure, and deployment bootstrap; only create-only +accepts this choice. + +Upgrade the **whole serving relay fleet** before callers request true. Older +writers may ignore the unknown field and insert an optional row. Checking the +created response for stored true detects that incompatible writer, but cannot +undo its insertion; a later existing-row lookup remains read-only. Turn caller +targeting off before rolling back the relay. Turning it off affects future +insertions only; it does not clear existing policy. + ## Commands ```sh diff --git a/mobile/ios/Runner/JumpToLatestGlassButton.swift b/mobile/ios/Runner/JumpToLatestGlassButton.swift index b53295c4a6..d317e5eceb 100644 --- a/mobile/ios/Runner/JumpToLatestGlassButton.swift +++ b/mobile/ios/Runner/JumpToLatestGlassButton.swift @@ -543,6 +543,7 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { case "sun": buttonIconName = "sun.max" case "moon": buttonIconName = "moon" case "trash": buttonIconName = "trash" + case "bell": buttonIconName = "bell" case "more": buttonIconName = "ellipsis" case "reply": buttonIconName = "arrowshape.turn.up.left" case "systemAppearance": buttonIconName = "circle.lefthalf.filled" diff --git a/mobile/ios/Runner/NativeTabler.swift b/mobile/ios/Runner/NativeTabler.swift index d13e33d2a8..c12fd204f1 100644 --- a/mobile/ios/Runner/NativeTabler.swift +++ b/mobile/ios/Runner/NativeTabler.swift @@ -117,6 +117,7 @@ enum NativeTabler { "link": 0xeade, // link "checkmark": 0xea5e, // check "checkmark.circle": 0xea67, // circle-check + "bell": 0xea35, // bell "bell.slash": 0xece9, // bell-off "number": 0xeabc, // hash — matches BuzzIcons.hash "bubble.left": 0xeaef, // message diff --git a/mobile/lib/features/activity/inbox_unread_provider.dart b/mobile/lib/features/activity/inbox_unread_provider.dart new file mode 100644 index 0000000000..26d4ab985b --- /dev/null +++ b/mobile/lib/features/activity/inbox_unread_provider.dart @@ -0,0 +1,22 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/read_state/read_state_provider.dart'; +import 'activity_provider.dart'; +import 'inbox_local_state_provider.dart'; +import 'inbox_read_state.dart'; + +/// The bell reflects the same unread rows shown by the Activity inbox. +final inboxHasUnreadProvider = Provider((ref) { + final items = ref.watch(inboxItemsProvider); + final readState = ref.watch(readStateProvider); + final localState = ref.watch(inboxLocalStateProvider); + if (!readState.isReady) return false; + return items.any( + (item) => !isInboxItemDone( + item, + markerOf: readState.effectiveTimestamp, + localUnreadOverrides: localState.unreadIds, + localDoneSet: localState.doneIds, + ), + ); +}); diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 9f4cdbf7ff..45d5052162 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -129,7 +129,7 @@ class ChannelsPage extends HookConsumerWidget { }); final WidgetBuilder settingsPageBuilder; - final Widget? messagesFilters; + final Widget Function(bool isCollapsed)? messagesFilters; final List Function(VoidCallback openSwitcher, GlobalKey avatarKey)? messagesActionsBuilder; final Widget? soloSliver; @@ -738,7 +738,9 @@ class ChannelsPage extends HookConsumerWidget { padding: EdgeInsets.only( right: 60 * headerCollapse.value / filterContentHeight, ), - child: messagesFilters, + child: messagesFilters!( + headerCollapse.value >= filterContentHeight, + ), ), ), body: _ChannelsBody( diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index 5b53ef5b29..307317c213 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -38,7 +38,7 @@ class _ChannelsBody extends StatelessWidget { @override Widget build(BuildContext context) { final inbox = Semantics( - label: 'Inbox', + label: 'Activity', button: true, onTap: onInbox, excludeSemantics: true, @@ -59,18 +59,20 @@ class _ChannelsBody extends StatelessWidget { child: Icon( BuzzIcons.bell, size: _kChannelIconSize, - color: channelNavigationForeground(context), + color: navigationPrimaryForeground(context), ), ), ), const SizedBox(width: _kChannelLabelGap), Expanded( child: Text( - 'Inbox', + 'Activity', maxLines: 1, overflow: TextOverflow.ellipsis, - style: navigationChatTitleTextStyle.copyWith( - color: channelNavigationForeground(context), + style: navigationSectionTextStyle.copyWith( + fontSize: 17, + height: 22 / 17, + color: navigationPrimaryForeground(context), ), ), ), @@ -110,9 +112,9 @@ class _ChannelsBody extends StatelessWidget { child: Padding( padding: const EdgeInsets.fromLTRB( Grid.gutter, - Grid.xs, + Grid.lg, Grid.gutter, - Grid.twelve, + 0, ), child: Semantics( header: true, @@ -133,7 +135,7 @@ class _ChannelsBody extends StatelessWidget { child: Padding( padding: const EdgeInsets.fromLTRB( Grid.gutter, - Grid.sm, + Grid.lg, Grid.gutter, 0, ), @@ -389,7 +391,7 @@ class _SliverChannelsList extends HookConsumerWidget { for (final section in userSections) ...[ if (section != userSections.first || starredStreamChannels.isNotEmpty) - const SizedBox(height: Grid.twelve), + const SizedBox(height: Grid.xxs), _CustomChannelSection( section: section, channels: sortChannelsForList( @@ -484,7 +486,7 @@ class _SliverChannelsList extends HookConsumerWidget { ), ], if (userSections.isNotEmpty || starredStreamChannels.isNotEmpty) - const SizedBox(height: Grid.twelve), + const SizedBox(height: Grid.xxs), _ChannelSection( title: 'Channels', expanded: channelsExpanded.value, @@ -499,7 +501,7 @@ class _SliverChannelsList extends HookConsumerWidget { onSortModeChange: (mode) => setSortMode('channels', mode), onSelectChannel: onSelectChannel, ), - const SizedBox(height: Grid.twelve), + const SizedBox(height: Grid.xxs), _ChannelSection( title: 'DMs', expanded: dmsExpanded.value, diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index cf985e2c32..0f1f60d7df 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -518,25 +518,28 @@ class _SectionLabel extends StatelessWidget { final Color color; @override - Widget build(BuildContext context) => Row( - children: [ - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: navigationSectionTextStyle.copyWith( - color: color, - fontWeight: FontWeight.w500, + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(top: Grid.xxs), + child: Row( + children: [ + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: navigationSectionTextStyle.copyWith( + color: color, + fontWeight: FontWeight.w500, + ), ), ), - ), - const SizedBox(width: Grid.quarter), - _SectionChevron( - expanded: expanded, - color: navigationAccessoryForeground(context), - ), - ], + const SizedBox(width: Grid.quarter), + _SectionChevron( + expanded: expanded, + color: navigationAccessoryForeground(context), + ), + ], + ), ); } diff --git a/mobile/lib/features/channels/me/me_page.dart b/mobile/lib/features/channels/me/me_page.dart index 83ec9b0b4f..7ad212d61d 100644 --- a/mobile/lib/features/channels/me/me_page.dart +++ b/mobile/lib/features/channels/me/me_page.dart @@ -211,7 +211,7 @@ class MePage extends HookConsumerWidget { ), for (final group in organization.groups) ...[ if (group != organization.groups.first) - const SizedBox(height: Grid.twelve), + const SizedBox(height: Grid.xxs), MeSection( key: ValueKey(group['id']), label: group['name'] as String, @@ -230,7 +230,7 @@ class MePage extends HookConsumerWidget { ), ], if (organization.groups.isNotEmpty) - const SizedBox(height: Grid.twelve), + const SizedBox(height: Grid.xxs), if (visible.isNotEmpty || organization.groups.isNotEmpty) MeSection( key: const ValueKey('me-ungrouped-chats'), diff --git a/mobile/lib/features/channels/me/me_section.dart b/mobile/lib/features/channels/me/me_section.dart index 4ca97df669..e9a43d753a 100644 --- a/mobile/lib/features/channels/me/me_section.dart +++ b/mobile/lib/features/channels/me/me_section.dart @@ -31,9 +31,11 @@ class MeSection extends HookWidget { borderRadius: BorderRadius.circular(Radii.md), onTap: () => expanded.value = !expanded.value, child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.xxs, - vertical: Grid.xxs, + padding: const EdgeInsets.fromLTRB( + Grid.xxs, + Grid.twelve, + Grid.xxs, + Grid.half, ), child: ConstrainedBox( constraints: const BoxConstraints(minHeight: 28), diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index 265880632b..aa35a2fdb0 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -6,14 +6,17 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/theme/buzz_icons.dart'; import '../../shared/community/community_membership_provider.dart'; import '../../shared/community/community_provider.dart'; import '../../shared/community/community_icon_provider.dart'; import '../../shared/community/community_avatar.dart'; import '../../shared/widgets/ios_message_create_button.dart'; +import '../../shared/widgets/ios_glass_navigation_button.dart'; import '../../shared/widgets/bottom_up_page_route.dart'; import '../../shared/widgets/mobile_tab_footer_backdrop.dart'; import '../activity/activity_page.dart'; +import '../activity/inbox_unread_provider.dart'; import '../channels/channels_page.dart'; import '../search/search_page.dart'; import 'messages_filters.dart'; @@ -56,6 +59,8 @@ class HomePage extends HookConsumerWidget { final communitySurfaceProgress = useState(0.0); final filter = useState('All'); + final hasUnreadInbox = ref.watch(inboxHasUnreadProvider); + final inboxLabel = hasUnreadInbox ? 'Activity, unread items' : 'Activity'; final reselect = useValueNotifier(0); final newChat = useValueNotifier(null); final newWorkspace = useValueNotifier(null); @@ -85,14 +90,14 @@ class HomePage extends HookConsumerWidget { void openPage(WidgetBuilder builder) => Navigator.of(context).push(MaterialPageRoute(builder: builder)); final solo = mePageBuilder?.call(context, newChat, newWorkspace); + void openInbox() => Navigator.of(context).push( + BottomUpPageRoute( + builder: (_) => const ActivityPage(), + reducedMotion: reducedMotion, + ), + ); final messages = ChannelsPage( settingsPageBuilder: settingsPageBuilder, - onInbox: () => Navigator.of(context).push( - BottomUpPageRoute( - builder: (_) => const ActivityPage(), - reducedMotion: reducedMotion, - ), - ), messagesActionsBuilder: (openSwitcher, avatarKey) { final moreActions = [ ( @@ -120,6 +125,84 @@ class HomePage extends HookConsumerWidget { (label: 'Settings', symbol: 'gearshape', onPressed: openSettings), ]; return [ + if (defaultTargetPlatform == TargetPlatform.iOS && + !nativeControlsVisible) + const SizedBox(width: 44, height: 44) + else + Stack( + alignment: Alignment.center, + children: [ + defaultTargetPlatform == TargetPlatform.iOS + ? IosGlassNavigationButton( + icon: IosGlassNavigationIcon.bell, + semanticLabel: inboxLabel, + width: 44, + height: 44, + controlSize: 44, + foregroundColor: context.colors.onSurface, + onPressed: openInbox, + ) + : IconButton.filledTonal( + tooltip: inboxLabel, + style: IconButton.styleFrom( + backgroundColor: + context.colors.surfaceContainerHighest, + foregroundColor: context.colors.onSurface, + minimumSize: const Size.square(48), + ), + icon: const Icon(BuzzIcons.bell), + onPressed: openInbox, + ), + Positioned.fill( + child: IgnorePointer( + child: ExcludeSemantics( + child: Center( + child: AnimatedOpacity( + opacity: hasUnreadInbox ? 1 : 0, + duration: reducedMotion + ? Duration.zero + : const Duration(milliseconds: 150), + curve: Curves.easeOut, + child: SizedBox.square( + key: const Key('home-inbox-badge'), + dimension: 24, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + top: 0, + right: 2, + child: Container( + key: const Key('home-inbox-dot'), + width: 10, + height: 10, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: context.colors.primary, + border: Border.all( + color: + defaultTargetPlatform == + TargetPlatform.android + ? context + .colors + .surfaceContainerHighest + : context.controlSurface, + width: 2, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ), + const SizedBox(width: Grid.xs), SizedBox( key: avatarKey, child: defaultTargetPlatform == TargetPlatform.iOS @@ -138,6 +221,7 @@ class HomePage extends HookConsumerWidget { ) : AndroidExpandingMenuButton( label: 'Community menu', + backgroundColor: context.colors.surfaceContainerHighest, actions: moreActions, dividerBefore: const {1}, width: 48, @@ -151,7 +235,8 @@ class HomePage extends HookConsumerWidget { ), ]; }, - messagesFilters: MessagesFilters( + messagesFilters: (isCollapsed) => MessagesFilters( + isCollapsed: isCollapsed, selected: filter.value, onSelected: (value) { filter.value = value; diff --git a/mobile/lib/features/home/messages_filters.dart b/mobile/lib/features/home/messages_filters.dart index 29bbb2277f..f55366cf6a 100644 --- a/mobile/lib/features/home/messages_filters.dart +++ b/mobile/lib/features/home/messages_filters.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import '../../shared/theme/theme.dart'; @@ -9,9 +10,11 @@ import '../../shared/widgets/filter_chip_bar.dart'; class MessagesFilters extends StatelessWidget { const MessagesFilters({ super.key, + this.isCollapsed = false, required this.selected, required this.onSelected, }); + final bool isCollapsed; final String selected; final ValueChanged onSelected; @@ -20,6 +23,14 @@ class MessagesFilters extends StatelessWidget { width: double.infinity, child: FilterChipBar( useNativeGlass: true, + matchNativeWidths: true, + labelOffset: defaultTargetPlatform == TargetPlatform.android + ? const Offset(-0.5, -1) + : Offset.zero, + side: defaultTargetPlatform == TargetPlatform.android && isCollapsed + ? BorderSide(color: context.colors.primary.withValues(alpha: 0.02)) + : BorderSide.none, + minControlHeight: defaultTargetPlatform == TargetPlatform.iOS ? 44 : 48, horizontalPadding: Grid.gutter, selected: selected, onSelected: (value) { diff --git a/mobile/lib/shared/widgets/android_expanding_menu_button.dart b/mobile/lib/shared/widgets/android_expanding_menu_button.dart index e9cda3e9a9..31b195d877 100644 --- a/mobile/lib/shared/widgets/android_expanding_menu_button.dart +++ b/mobile/lib/shared/widgets/android_expanding_menu_button.dart @@ -18,6 +18,7 @@ class AndroidExpandingMenuButton extends HookWidget { this.label = 'New', this.expansionBoundsContext, this.trigger, + this.backgroundColor, this.width = 112, this.height = 56, this.dividerBefore = const {}, @@ -29,6 +30,7 @@ class AndroidExpandingMenuButton extends HookWidget { final BuildContext? expansionBoundsContext; final String label; final Widget? trigger; + final Color? backgroundColor; final double width, height; final Set dividerBefore; final Map sectionTitles; @@ -43,6 +45,7 @@ class AndroidExpandingMenuButton extends HookWidget { child: Opacity( opacity: open.value ? 0 : 1, child: NavigationButtonSurface( + backgroundColor: backgroundColor, child: Builder( builder: (buttonContext) => Tooltip( message: label, @@ -74,6 +77,7 @@ class AndroidExpandingMenuButton extends HookWidget { final captured = actions; final route = _CreateMenuRoute( anchor: rect, + backgroundColor: backgroundColor ?? context.controlSurface, expansionBounds: expansionBounds, label: label, trigger: trigger ?? const _NewLabel(), @@ -130,6 +134,7 @@ class _CreateMenuRoute extends PopupRoute { required this.expansionBounds, required this.label, required this.trigger, + required this.backgroundColor, required this.dividerBefore, required this.sectionTitles, required this.actions, @@ -140,6 +145,7 @@ class _CreateMenuRoute extends PopupRoute { final Rect? expansionBounds; final String label; final Widget trigger; + final Color backgroundColor; final Set dividerBefore; final Map sectionTitles; final List<({String label, String symbol, VoidCallback? onPressed})> actions; @@ -230,9 +236,7 @@ class _CreateMenuRoute extends PopupRoute { child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 16 * t, sigmaY: 16 * t), child: Material( - color: context.controlSurface.withValues( - alpha: 1 - .12 * t, - ), + color: backgroundColor.withValues(alpha: 1 - .12 * t), child: Stack( children: [ Positioned.fill( diff --git a/mobile/lib/shared/widgets/filter_chip_bar.dart b/mobile/lib/shared/widgets/filter_chip_bar.dart index 47dc896e98..2d58a2a635 100644 --- a/mobile/lib/shared/widgets/filter_chip_bar.dart +++ b/mobile/lib/shared/widgets/filter_chip_bar.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'ios_glass_navigation_button.dart'; @@ -46,6 +48,18 @@ class FilterChipBar extends StatelessWidget { /// Uses native glass capsules on iOS for navigation-style filters. final bool useNativeGlass; + /// Uses the native iOS label widths for Material capsules too. + final bool matchNativeWidths; + + /// Optional outline for Material capsules. + final BorderSide side; + + /// Optical adjustment for the visible label within its centered layout box. + final Offset labelOffset; + + /// Minimum visible capsule height; the hit target may be larger. + final double minControlHeight; + /// Material density applied to each chip. final VisualDensity visualDensity; @@ -65,6 +79,10 @@ class FilterChipBar extends StatelessWidget { required this.onSelected, this.expandItems = false, this.useNativeGlass = false, + this.matchNativeWidths = false, + this.side = BorderSide.none, + this.labelOffset = Offset.zero, + this.minControlHeight = 0, this.visualDensity = VisualDensity.compact, this.chipVerticalPadding = Grid.quarter, this.barVerticalPadding = Grid.xxs, @@ -73,21 +91,25 @@ class FilterChipBar extends StatelessWidget { @override Widget build(BuildContext context) { + final widths = [ + for (final item in items) + (TextPainter( + text: TextSpan( + text: item.label, + style: filterChipTextStyle.copyWith( + fontWeight: FontWeight.w600, + ), + ), + textScaler: MediaQuery.textScalerOf(context), + textDirection: Directionality.of(context), + )..layout()).width + + 32, + ]; if (useNativeGlass && defaultTargetPlatform == TargetPlatform.iOS) { - final textStyle = filterChipTextStyle.copyWith( - fontWeight: FontWeight.w600, + final height = math.max( + MediaQuery.textScalerOf(context).scale(16) * 1.4 + 16, + math.max(48.0, minControlHeight), ); - final height = (MediaQuery.textScalerOf(context).scale(16) * 1.4 + 16) - .clamp(48.0, double.infinity); - final widths = [ - for (final item in items) - (TextPainter( - text: TextSpan(text: item.label, style: textStyle), - textScaler: MediaQuery.textScalerOf(context), - textDirection: Directionality.of(context), - )..layout()).width + - 32, - ]; return LayoutBuilder( builder: (context, constraints) { final available = constraints.maxWidth - horizontalPadding * 2; @@ -122,7 +144,9 @@ class FilterChipBar extends StatelessWidget { isSelected: selected == items[index].id, width: widths[index] + extra, height: height, - controlSize: height - 8, + controlSize: math + .max(height - 8, minControlHeight) + .toDouble(), fillWidth: true, foregroundColor: context.colors.primary, ), @@ -139,6 +163,11 @@ class FilterChipBar extends StatelessWidget { if (index > 0) const SizedBox(width: Grid.xxs), if (expandItems) Expanded(child: _chip(context, items[index], fillWidth: true)) + else if (matchNativeWidths) + SizedBox( + width: widths[index], + child: _chip(context, items[index], fillWidth: true), + ) else _chip(context, items[index]), ], @@ -207,11 +236,26 @@ class FilterChipBar extends StatelessWidget { textAlign: fillWidth ? TextAlign.center : TextAlign.start, style: labelStyle, ); + final sizedLabel = minControlHeight > 0 + ? ConstrainedBox( + constraints: BoxConstraints( + minHeight: math.max( + 0, + minControlHeight - chipVerticalPadding * 2, + ), + ), + child: Center( + widthFactor: 1, + heightFactor: 1, + child: Transform.translate(offset: labelOffset, child: label), + ), + ) + : label; final centeredLabel = Align( alignment: Alignment.center, widthFactor: fillWidth ? null : 1, heightFactor: 1, - child: label, + child: sizedLabel, ); final chip = FilterChip( selected: isSelected, @@ -221,7 +265,7 @@ class FilterChipBar extends StatelessWidget { : RoundedRectangleBorder( borderRadius: BorderRadius.circular(Radii.lg), ), - side: BorderSide.none, + side: side, label: fillWidth ? SizedBox(width: double.infinity, child: centeredLabel) : centeredLabel, @@ -231,13 +275,21 @@ class FilterChipBar extends StatelessWidget { horizontal: fillWidth ? Grid.quarter : Grid.twelve, vertical: chipVerticalPadding, ), - visualDensity: visualDensity, + visualDensity: minControlHeight > 0 + ? VisualDensity.standard + : visualDensity, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ); - final control = fillWidth - ? SizedBox(width: double.infinity, child: chip) + final sizedChip = minControlHeight > 0 + ? ConstrainedBox( + constraints: BoxConstraints(minHeight: minControlHeight), + child: chip, + ) : chip; + final control = fillWidth + ? SizedBox(width: double.infinity, child: sizedChip) + : sizedChip; if (!item.hasUnread) return control; return Semantics( label: '$text, unread', diff --git a/mobile/lib/shared/widgets/frosted_app_bar.dart b/mobile/lib/shared/widgets/frosted_app_bar.dart index 2018441721..778ab762c6 100644 --- a/mobile/lib/shared/widgets/frosted_app_bar.dart +++ b/mobile/lib/shared/widgets/frosted_app_bar.dart @@ -538,6 +538,7 @@ IosNavigationAction? _nativeAction(Widget? widget) { if (widget is IosGlassNavigationButton) { final symbol = switch (widget.icon) { IosGlassNavigationIcon.more => 'ellipsis', + IosGlassNavigationIcon.bell => 'bell', IosGlassNavigationIcon.trash => 'trash', IosGlassNavigationIcon.reply => 'arrowshape.turn.up.left', IosGlassNavigationIcon.back => 'chevron.backward', diff --git a/mobile/lib/shared/widgets/ios_glass_navigation_button.dart b/mobile/lib/shared/widgets/ios_glass_navigation_button.dart index 9fd1236947..d12ec9031d 100644 --- a/mobile/lib/shared/widgets/ios_glass_navigation_button.dart +++ b/mobile/lib/shared/widgets/ios_glass_navigation_button.dart @@ -30,6 +30,7 @@ enum IosGlassNavigationIcon { more, reply, trash, + bell, } /// Leading width used by iOS channel-style headers. @@ -246,6 +247,7 @@ class IosGlassNavigationButton extends HookWidget { : Icon( switch (icon) { IosGlassNavigationIcon.more => BuzzIcons.ellipsis, + IosGlassNavigationIcon.bell => BuzzIcons.bell, IosGlassNavigationIcon.trash => BuzzIcons.trash2, IosGlassNavigationIcon.reply => BuzzIcons.cornerUpLeft, diff --git a/mobile/lib/shared/widgets/navigation_button_surface.dart b/mobile/lib/shared/widgets/navigation_button_surface.dart index a62ec6e65c..e12cf4e8ad 100644 --- a/mobile/lib/shared/widgets/navigation_button_surface.dart +++ b/mobile/lib/shared/widgets/navigation_button_surface.dart @@ -9,11 +9,15 @@ class NavigationButtonSurface extends StatelessWidget { const NavigationButtonSurface({ required this.child, this.capsule = false, + this.backgroundColor, super.key, }); final Widget child; + /// Optional Material background; native iOS glass retains its own treatment. + final Color? backgroundColor; + /// Uses fully rounded ends instead of device-concentric corners. final bool capsule; @@ -22,7 +26,7 @@ class NavigationButtonSurface extends StatelessWidget { if (NavigationGlassGroup.maybeOf(context)) return child; if (defaultTargetPlatform != TargetPlatform.iOS) { return Material( - color: context.controlSurface, + color: backgroundColor ?? context.controlSurface, shape: const StadiumBorder(), clipBehavior: Clip.antiAlias, child: child, diff --git a/mobile/test/features/activity/inbox_unread_provider_test.dart b/mobile/test/features/activity/inbox_unread_provider_test.dart new file mode 100644 index 0000000000..ed8a749574 --- /dev/null +++ b/mobile/test/features/activity/inbox_unread_provider_test.dart @@ -0,0 +1,86 @@ +import 'package:buzz/features/activity/activity_provider.dart'; +import 'package:buzz/features/activity/feed_item.dart'; +import 'package:buzz/features/activity/inbox_item.dart'; +import 'package:buzz/features/activity/inbox_local_state_provider.dart'; +import 'package:buzz/features/activity/inbox_unread_provider.dart'; +import 'package:buzz/shared/read_state/read_state_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class _ReadState extends ReadStateNotifier { + @override + ReadStateState build() => const ReadStateState.inert(); + + void load(Map contexts) => state = ReadStateState( + isReady: true, + pubkey: 'reader', + contexts: contexts, + version: state.version + 1, + ); +} + +class _LocalState extends InboxLocalStateNotifier { + @override + InboxLocalState build() => const InboxLocalState.empty(); +} + +FeedItem item(String id, {String? channelId}) => FeedItem( + id: id, + kind: 9, + pubkey: 'sender', + content: 'An update', + createdAt: 100, + channelId: channelId, + channelName: 'general', + tags: const [], + category: 'mention', +); + +void main() { + test('bell follows read markers and local unread overrides', () { + final container = ProviderContainer( + overrides: [ + inboxItemsProvider.overrideWithValue( + buildInboxItems([item('mention', channelId: 'channel')]), + ), + readStateProvider.overrideWith(_ReadState.new), + inboxLocalStateProvider.overrideWith(_LocalState.new), + ], + ); + addTearDown(container.dispose); + expect(container.read(inboxHasUnreadProvider), isFalse); + final read = container.read(readStateProvider.notifier) as _ReadState; + read.load({}); + expect(container.read(inboxHasUnreadProvider), isTrue); + read.load({'channel': 100}); + expect(container.read(inboxHasUnreadProvider), isFalse); + final local = container.read(inboxLocalStateProvider.notifier); + local.markUnread(['mention']); + expect(container.read(inboxHasUnreadProvider), isTrue); + local.clearUnread(['mention']); + expect(container.read(inboxHasUnreadProvider), isFalse); + }); + + test('channel-less items clear when done and empty inbox has no dot', () { + final container = ProviderContainer( + overrides: [ + inboxItemsProvider.overrideWithValue( + buildInboxItems([item('approval')]), + ), + readStateProvider.overrideWith(_ReadState.new), + inboxLocalStateProvider.overrideWith(_LocalState.new), + ], + ); + addTearDown(container.dispose); + (container.read(readStateProvider.notifier) as _ReadState).load({}); + expect(container.read(inboxHasUnreadProvider), isTrue); + container.read(inboxLocalStateProvider.notifier).markDone('approval'); + expect(container.read(inboxHasUnreadProvider), isFalse); + container.updateOverrides([ + inboxItemsProvider.overrideWithValue(const []), + readStateProvider.overrideWith(_ReadState.new), + inboxLocalStateProvider.overrideWith(_LocalState.new), + ]); + expect(container.read(inboxHasUnreadProvider), isFalse); + }); +} diff --git a/mobile/test/features/home/home_page_test.dart b/mobile/test/features/home/home_page_test.dart index 25424ddc65..52a8e1d87f 100644 --- a/mobile/test/features/home/home_page_test.dart +++ b/mobile/test/features/home/home_page_test.dart @@ -2,6 +2,7 @@ import 'package:buzz/features/channels/default_agent_launcher.dart'; import 'package:buzz/features/agents/agents_page.dart'; import 'package:buzz/features/channels/me/me_section.dart'; import 'package:buzz/features/activity/activity_page.dart'; +import 'package:buzz/features/activity/inbox_unread_provider.dart'; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/shared/me/default_agent.dart'; @@ -10,6 +11,7 @@ import 'package:buzz/features/home/home_page.dart'; import 'package:buzz/features/home/messages_filters.dart'; import 'package:buzz/features/home/messages_navigation_bar.dart'; import 'package:buzz/shared/widgets/ios_message_create_button.dart'; +import 'package:buzz/shared/widgets/ios_glass_navigation_button.dart'; import 'package:buzz/features/search/search_page.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:flutter/foundation.dart'; @@ -18,9 +20,21 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +// Production iOS capsules fit the semibold label plus 16 points on each side. +double nativeFilterWidth(String label) => + (TextPainter( + text: TextSpan( + text: label, + style: filterChipTextStyle.copyWith(fontWeight: FontWeight.w600), + ), + textDirection: TextDirection.ltr, + )..layout()).width + + 32; + void main() { Future buildHome({ bool longSolo = false, + bool inboxUnread = false, bool disableAnimations = false, Gradient? topSectionGradient, ValueChanged>? onNewChatAction, @@ -30,6 +44,7 @@ void main() { return ProviderScope( overrides: [ savedPrefsProvider.overrideWithValue(prefs), + inboxHasUnreadProvider.overrideWithValue(inboxUnread), defaultAgentProvider.overrideWith((ref) => Stream.value(null)), channelsProvider.overrideWith(_Channels.new), ], @@ -121,6 +136,23 @@ void main() { expect(header.titleStyle?.fontSize, 22); expect(header.centerTitle, isFalse); + for (final label in ['All', 'Solo', 'Team']) { + final chip = find.ancestor( + of: find.descendant( + of: find.byType(MessagesFilters), + matching: find.text(label), + ), + matching: find.byType(FilterChip), + ); + expect(tester.getSize(chip).height, 48); + final paintedChip = find.descendant( + of: chip, + matching: find.byType(Material), + ); + expect(tester.getSize(paintedChip).height, 48); + expect(tester.getSize(paintedChip).width, nativeFilterWidth(label)); + } + await tester.tap( find.descendant( of: find.byType(MessagesFilters), @@ -161,7 +193,8 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Personal workspace').hitTestable(), findsOneWidget); expect(find.text('General').hitTestable(), findsOneWidget); - await tester.tap(find.text('Inbox')); + expect(find.text('Activity'), findsNothing); + await tester.tap(find.byTooltip('Activity')); await tester.pumpAndSettle(); expect(find.byType(ActivityPage), findsOneWidget); Navigator.of(tester.element(find.byType(ActivityPage))).pop(); @@ -202,6 +235,17 @@ void main() { final expandedHeader = tester.widget( find.byType(FrostedAppBar), ); + BorderSide filterOutline() => tester + .widget( + find + .descendant( + of: find.byType(MessagesFilters), + matching: find.byType(FilterChip), + ) + .first, + ) + .side!; + expect(filterOutline(), BorderSide.none); scroll.jumpTo(expandedHeader.bottomHeight / 4); await tester.pump(); expect( @@ -216,6 +260,7 @@ void main() { .opacity, closeTo(0.75, 0.01), ); + expect(filterOutline(), BorderSide.none); scroll.jumpTo(expandedHeader.bottomHeight); await tester.pump(); expect( @@ -226,9 +271,12 @@ void main() { .opacity, 0, ); + expect(filterOutline().width, 1); + expect(filterOutline().color.a, closeTo(0.02, 0.001)); scroll.jumpTo(0); await tester.pump(); expect(tester.getTopLeft(title).dy, titleTop); + expect(filterOutline(), BorderSide.none); await tester.drag( find.byType(CustomScrollView).first, const Offset(0, -2000), @@ -293,6 +341,54 @@ void main() { }); } + for (final platform in [TargetPlatform.android, TargetPlatform.iOS]) { + testWidgets('$platform bell follows unread inbox state', (tester) async { + debugDefaultTargetPlatformOverride = platform; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + for (final unread in [false, true, false]) { + await tester.pumpWidget(await buildHome(inboxUnread: unread)); + await tester.pumpAndSettle(); + expect( + tester.getCenter(find.byKey(const Key('home-inbox-dot'))) - + tester.getTopLeft(find.byKey(const Key('home-inbox-badge'))), + const Offset(17, 5), + ); + final opacity = tester.widget( + find.ancestor( + of: find.byKey(const Key('home-inbox-badge')), + matching: find.byType(AnimatedOpacity), + ), + ); + expect(opacity.opacity, unread ? 1 : 0); + expect(opacity.duration, const Duration(milliseconds: 150)); + expect( + tester.getSize(find.byKey(const Key('home-inbox-badge'))), + const Size.square(24), + ); + expect( + find.byTooltip(unread ? 'Activity, unread items' : 'Activity'), + findsOneWidget, + ); + } + await tester.pumpWidget( + await buildHome(inboxUnread: true, disableAnimations: true), + ); + await tester.pump(); + expect( + tester + .widget( + find.ancestor( + of: find.byKey(const Key('home-inbox-badge')), + matching: find.byType(AnimatedOpacity), + ), + ) + .duration, + Duration.zero, + ); + debugDefaultTargetPlatformOverride = null; + }); + } + testWidgets( 'iOS shows Messages above filters with the native creation menu', (tester) async { @@ -348,6 +444,48 @@ void main() { closeTo(initialWidth, .01), ); expect(search.hitTestable(), findsOneWidget); + final inbox = find.byWidgetPredicate( + (w) => w is IosGlassNavigationButton && w.semanticLabel == 'Activity', + ); + expect(inbox, findsOneWidget); + expect(find.text('Activity'), findsNothing); + final inboxButton = tester.widget(inbox); + expect(inboxButton.icon, IosGlassNavigationIcon.bell); + + for (final label in ['All', 'Solo', 'Team']) { + final chip = tester.widget( + find.descendant( + of: find.byType(MessagesFilters), + matching: find.byWidgetPredicate( + (w) => w is IosGlassNavigationButton && w.label == label, + ), + ), + ); + expect(chip.controlSize, inboxButton.controlSize); + expect(chip.width, nativeFilterWidth(label)); + } + expect(tester.getSize(inbox), const Size(44, 44)); + final communityTrigger = find.byWidgetPredicate( + (w) => + w is IosMessageCreateButton && + w.accessibilityLabel == 'Community menu', + ); + expect( + tester.getRect(inbox).right, + lessThan(tester.getRect(communityTrigger).left), + ); + inboxButton.onPressed!(); + await tester.pumpAndSettle(); + expect(find.byType(ActivityPage), findsOneWidget); + expect( + find.byWidgetPredicate( + (w) => w is IosGlassNavigationButton && w.semanticLabel == 'Activity', + skipOffstage: false, + ), + findsNothing, + ); + Navigator.of(tester.element(find.byType(ActivityPage))).pop(); + await tester.pumpAndSettle(); final communityMenu = tester.widget( find.byWidgetPredicate( (w) => diff --git a/perf/LOADGEN.md b/perf/LOADGEN.md index 15c748532a..ef472cc182 100644 --- a/perf/LOADGEN.md +++ b/perf/LOADGEN.md @@ -168,9 +168,24 @@ that requires membership rejects both `seed` and `run`. With every member: the humans and agents assigned to each channel up to the largest ramp step, and the hot-thread root and authors in its channel. It sends kind:9000 events of up to 100 members, `seed_concurrency` at a time, -signed by the community owner, before posting any history. A rerun adds +signed by the channel's creator, before posting any history. A rerun adds nobody new, and any member the relay refuses fails the seed. Seeded history -in a private channel is posted by its own members. +in a private channel is posted by its own human members, or by its creator +when it has none. + +The relay limits HTTP calls per pubkey, so `seed` spreads its writes across +signers. The community owner creates its first 8 channels and each further +block of 8 has its own creator identity, derived from `run_id`. A creator +signs its channels' creation, member adds and thread roots (`run` looks roots +up by it), so no creator nears the quota however many channels the community +has. History and replies rotate across humans. A write the relay still +throttles waits out the window the relay names and is retried, up to 10 +times, before it counts as failed. Each rejected attempt of a seed post counts as throttled, +and the post's latency covers only its accepted attempt. + +A private fixture with more than 8 channels per community that was seeded +before channels had creators cannot be reseeded, and `run` finds none of its +roots past the first 8 channels. Seed it again under a fresh `run_id`. ## Desktop clients diff --git a/perf/scenarios/local-desktop.toml b/perf/scenarios/local-desktop.toml index 518fa65ee7..712e03c201 100644 --- a/perf/scenarios/local-desktop.toml +++ b/perf/scenarios/local-desktop.toml @@ -4,7 +4,7 @@ # `desktop.mode = "legacy"` to compare the client before its volume fixes. # cargo run -p buzz-test-client --bin buzz-loadgen -- seed perf/scenarios/local-desktop.toml # cargo run -p buzz-test-client --bin buzz-loadgen -- run perf/scenarios/local-desktop.toml -run_id = "local-desktop" +run_id = "local-desktop-2" [target] ws_url = "ws://localhost:3000" diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index ece60dade8..b49a0ca444 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -526,6 +526,13 @@ run_unit_tests() { run_test_step "buzz-relay invitation cutoff configuration" \ cargo test -p buzz-relay --lib config::invite_cutoff_tests:: -- --nocapture + run_test_step "buzz-relay runtime concurrency caps" \ + cargo test -p buzz-relay --lib concurrency_limits::tests:: -- --nocapture + run_test_step "buzz-relay feature-flag provider selection" \ + cargo test -p buzz-relay --lib feature_flags::tests:: -- --nocapture + run_test_step "buzz-relay AUTH concurrency shed" \ + cargo test -p buzz-relay --lib handlers::auth::tests::auth_cap_sheds_a_verified_login_with_a_bare_close -- --exact --nocapture + run_test_step "buzz-relay mesh receive lifetime regression" \ cargo test -p buzz-relay --lib mesh_boot::tests::demo_echo_retains_pending_receive_across_housekeeping_ticks -- --exact --nocapture