From c4c86006f2d67b2ea9055780129ee7ce95c54e0a Mon Sep 17 00:00:00 2001 From: Jarrod Sibbison <72240382+jsibbison-square@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:04:40 +1000 Subject: [PATCH 01/12] feat(relay): deliver pubkey mentions to relay companions (#7793) We are introducing a companion service to the relay that needs pubkey event mentions for pubkeys it manages. This PR introduces an outbox for pubkey mentions that will be sent to these "operator listener" services. ## Summary Deliver mentions of registered pubkeys to deployment-global operator listeners. - Configure listeners with `BUZZ_OPERATOR_LISTENERS`; listeners manage target pubkeys through NIP-98-authenticated `POST`/`DELETE /operator/listener/pubkeys` requests. - During supported event ingestion, match `p` tags to registrations and insert one outbox row per listener in the event transaction. - A background worker claims up to 10 rows, sends NIP-98-signed notifications, and retries transient failures with exponential backoff for up to 9 attempts (idle polling backs off from 250 ms to 2 s). - Outbox rows are ineligible for delivery after 15 minutes and are reaped every 5 minutes. Registered pubkeys live for 30 days; re-registering renews them, and a daily reaper removes expired registrations. - Keep persistence app-managed: no database triggers, functions, foreign keys, or separate match queue. ## Changed behavior Listener outbox rows are enqueued inside the shared event-insert and thread-metadata transactions; enqueue failure rolls back the event. `event_mentions` behavior is unchanged: ordinary event/reaction mention indexing remains post-commit best effort with warning-only failures. ## Testing - `cargo fmt --all` - `cargo test -p buzz-db --lib` - `cargo test -p buzz-relay --lib config::tests::operator_listener_routes` - `cargo check -p buzz-db -p buzz-relay` --- .env.example | 6 + crates/buzz-db/src/lib.rs | 7 +- crates/buzz-db/src/runtime/migration.rs | 63 +- crates/buzz-db/src/runtime/tests.rs | 20 + .../tests/thread_window_postgres_tests.rs | 2 +- crates/buzz-db/src/store/event.rs | 15 +- crates/buzz-db/src/store/mod.rs | 2 + crates/buzz-db/src/store/operator_listener.rs | 1036 +++++++++++++++++ crates/buzz-relay/src/api/operator.rs | 309 ++++- crates/buzz-relay/src/config.rs | 222 +++- crates/buzz-relay/src/lib.rs | 3 + crates/buzz-relay/src/main.rs | 18 + crates/buzz-relay/src/nip98.rs | 22 + crates/buzz-relay/src/operator_listener.rs | 776 ++++++++++++ crates/buzz-relay/src/push_runtime.rs | 23 +- crates/buzz-relay/src/router.rs | 5 + .../0050_operator_listener_mentions.sql | 57 + schema/schema.sql | 47 +- 18 files changed, 2587 insertions(+), 46 deletions(-) create mode 100644 crates/buzz-db/src/store/operator_listener.rs create mode 100644 crates/buzz-relay/src/nip98.rs create mode 100644 crates/buzz-relay/src/operator_listener.rs create mode 100644 migrations/0050_operator_listener_mentions.sql diff --git a/.env.example b/.env.example index 8e0c9c28fd..1216730968 100644 --- a/.env.example +++ b/.env.example @@ -120,6 +120,12 @@ BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns # and provisioning requests fail closed until it is set. # RELAY_OPERATOR_API_ORIGIN=http://127.0.0.1:3000 +# Deployment-global operator-listener routes. Each configured Nostr identity +# may register target pubkeys and receives mention notifications at its URL. +# Format: pubkey:deliveryUrl;pubkey:deliveryUrl +# BUZZ_OPERATOR_LISTENER_TIMEOUT_MS=5000 +# BUZZ_OPERATOR_LISTENERS=<64-char hex pubkey>:https://listener.example/mentions + # Optional relay-owned KLIPY key. When set, NIP-11 advertises GIF search and # authenticated desktop clients use this relay as the metadata/search proxy. # Keep the real value in your deployment's secret manager; never commit it. diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index e70c5dd85b..27beed5173 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -63,9 +63,10 @@ pub(crate) use runtime::{ }; pub use store::{ admin_moderation, allowlist, api_token, archived_identities, channel, channel_members, - community, deletion, dm, event, feed, git_repo, moderation, partition, product_feedback, push, - reaction, read_state, relay_admin_actions, relay_invite, relay_members, relay_operators, - reminder, replaceable, storage_accounting, thread, thread_window, usage, user, workflow, + community, deletion, dm, event, feed, git_repo, moderation, operator_listener, partition, + product_feedback, push, reaction, read_state, relay_admin_actions, relay_invite, relay_members, + relay_operators, reminder, replaceable, storage_accounting, thread, thread_window, usage, user, + workflow, }; pub use allowlist::AllowlistEntry; diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 20496ac1cf..1b210ed0f7 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -490,6 +490,8 @@ mod postgres_tests { "relay_admin_outbox", "relay_operator_audit", "storage_accounting_snapshots", + "operator_listener_pubkeys", + "operator_listener_outbox", ] { if normalized[insert_pos..].contains(&format!("'{value}'")) { globals.insert(value.to_owned()); @@ -703,7 +705,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 49); + assert_eq!(migrations.len(), 50); assert_eq!(migrations[48].version, 49); assert!(migrations[48] .sql @@ -1293,10 +1295,63 @@ mod postgres_tests { .sql .as_str() .contains("CREATE TABLE storage_accounting_snapshots")); - // schema.sql exclusion list must match the restored (pre-0041) body. + assert_eq!(migrations[47].version, 48); + assert!(migrations[47] + .sql + .as_str() + .contains("ADD COLUMN hash_version SMALLINT")); + assert_eq!(migrations[49].version, 50); + assert!(migrations[49] + .sql + .as_str() + .contains("CREATE TABLE operator_listener_pubkeys")); + assert!(migrations[49] + .sql + .as_str() + .contains("CREATE TABLE operator_listener_outbox")); + assert!(migrations[49] + .sql + .as_str() + .contains("CREATE OR REPLACE FUNCTION community_write_fence_excluded_table")); + assert!(migrations[49] + .sql + .as_str() + .contains("'rate_limit_violations', 'operator_listener_outbox'")); + for index in [ + "operator_listener_pubkeys_target", + "operator_listener_pubkeys_created_at", + "operator_listener_outbox_due", + "operator_listener_outbox_recovery", + "operator_listener_outbox_created_at", + ] { + assert!( + migrations[49].sql.as_str().contains(index), + "migration 0050 must declare {index}" + ); + assert!( + desired_schema.contains(index), + "schema.sql must declare {index}" + ); + } + for index_shape in [ + "ON operator_listener_outbox (next_attempt_at, created_at, id)", + "ON operator_listener_outbox (lease_until, created_at, id)", + ] { + assert!( + migrations[49].sql.as_str().contains(index_shape), + "migration 0050 must declare {index_shape}" + ); + assert!( + desired_schema.contains(index_shape), + "schema.sql must declare {index_shape}" + ); + } + // schema.sql keeps the restored (pre-0041) deletion exclusions and also + // leaves the deployment-global listener outbox outside tenant fencing. assert!( - desired_schema.contains("'rate_limit_violations'\n ]::TEXT[])"), - "schema.sql exclusion list must match the pre-0041 body after ledger removal" + desired_schema + .contains("'rate_limit_violations', 'operator_listener_outbox'\n ]::TEXT[])"), + "schema.sql must exclude the deployment-global listener outbox from tenant fencing" ); } diff --git a/crates/buzz-db/src/runtime/tests.rs b/crates/buzz-db/src/runtime/tests.rs index 7d70d0a643..21d0e73823 100644 --- a/crates/buzz-db/src/runtime/tests.rs +++ b/crates/buzz-db/src/runtime/tests.rs @@ -638,6 +638,26 @@ async fn migration_schema_database_guard_covers_legacy_writer_and_nip09_deletion assert_eq!(watermark.1, c.id.as_bytes().as_slice()); } +#[tokio::test] +#[ignore = "requires Postgres"] +async fn migration_schema_operator_listener_outbox_is_not_tenant_scoped() { + let db = setup_db().await; + let community = CommunityId::from_uuid(make_community(&db.pool).await); + let inventory = db + .deletion_store() + .inventory_schema(community) + .await + .expect("migrated deletion catalog should validate"); + + assert!( + !inventory + .scoped_tables + .iter() + .any(|table| table == "operator_listener_outbox"), + "operator-listener outbox is deployment-global, not tenant-scoped" + ); +} + // ---- Read-replica routing ------------------------------------------------ // // These tests pin the routing contract of `Db::read()` and the two routed diff --git a/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs b/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs index 6062528d55..5ca0185af5 100644 --- a/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs +++ b/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs @@ -311,7 +311,7 @@ async fn migration_schema_thread_window_prebuild_does_not_queue_behind_writer() production_result.is_ok(), "production migrator must preserve ingestion progress: {production_result:?}" ); - assert_eq!(version, 49); + assert_eq!(version, 50); assert_eq!(final_oid, oid, "prebuild must not be replaced"); assert_eq!(count, 4, "all writer witnesses must persist"); } diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index 411c518240..537f338030 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -355,12 +355,15 @@ pub async fn insert_event( event: &Event, channel_id: Option, ) -> Result<(StoredEvent, bool)> { - let mut connection = crate::observability::acquire_writer( + let connection = crate::observability::acquire_writer( pool, crate::observability::WriterOperation::EventWrite, ) .await?; - insert_event_on(&mut connection, community_id, event, channel_id).await + let mut tx = Transaction::begin(connection, None).await?; + let result = insert_event_in_transaction(&mut tx, community_id, event, channel_id).await?; + tx.commit().await?; + Ok(result) } /// Insert a Nostr event in a caller-owned PostgreSQL transaction. @@ -374,7 +377,11 @@ pub async fn insert_event_in_transaction( event: &Event, channel_id: Option, ) -> Result<(StoredEvent, bool)> { - insert_event_on(tx.as_mut(), community_id, event, channel_id).await + let result = insert_event_on(tx.as_mut(), community_id, event, channel_id).await?; + if result.1 { + crate::operator_listener::enqueue_mentions_in_transaction(tx, community_id, event).await?; + } + Ok(result) } async fn insert_event_on( @@ -1528,6 +1535,8 @@ pub(crate) async fn insert_event_with_thread_metadata_tx( } } } + + crate::operator_listener::enqueue_mentions_in_transaction(tx, community_id, event).await?; } Ok(( diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index 559460e15b..51b8717db1 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -26,6 +26,8 @@ pub mod feed; pub mod git_repo; /// Community moderation: reports, bans/timeouts, audit actions. pub mod moderation; +/// Deployment-global operator-listener mention registrations and delivery queues. +pub mod operator_listener; /// Monthly table partition management. pub mod partition; /// Buzz product-feedback sidecar persistence. diff --git a/crates/buzz-db/src/store/operator_listener.rs b/crates/buzz-db/src/store/operator_listener.rs new file mode 100644 index 0000000000..7b08a34572 --- /dev/null +++ b/crates/buzz-db/src/store/operator_listener.rs @@ -0,0 +1,1036 @@ +//! Deployment-global operator-listener mention registrations and delivery outbox. + +use std::collections::HashSet; + +use buzz_datastore_tracing::datastore_span; +use chrono::{DateTime, TimeDelta, Utc}; +use nostr::Event; +use sqlx::{PgPool, Postgres, QueryBuilder, Row as _, Transaction}; +use uuid::Uuid; + +use crate::error::Result; +use crate::{observability, CommunityId, Db}; + +/// Event kinds whose `p` tags represent user-visible message mentions. +pub const OPERATOR_LISTENER_MENTION_KINDS: [u32; 4] = [9, 40002, 45001, 45003]; +/// Maximum notification attempts before a delivery becomes terminally failed. +pub const MAX_DELIVERY_ATTEMPTS: i32 = 9; +/// Maximum time a queued delivery remains useful when no worker can route it. +pub const OUTBOX_RETENTION: TimeDelta = TimeDelta::minutes(15); +/// Registration lifetime before the daily cleanup removes it. +pub const PUBKEY_RETENTION: TimeDelta = TimeDelta::days(30); + +async fn acquire_maintenance_writer( + pool: &PgPool, +) -> sqlx::Result> { + observability::acquire_writer(pool, observability::WriterOperation::Maintenance).await +} + +/// A claimed operator-listener notification. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClaimedDelivery { + /// Durable delivery identifier and claim fence key. + pub id: Uuid, + /// Claim fencing token. + pub claim_id: Uuid, + /// Configured operator-listener identity. + pub listener_pubkey: Vec, + /// Registered target identity mentioned by the event. + pub target_pubkey: Vec, + /// Community containing the event. + pub community: CommunityId, + /// Community host used in the notification payload. + pub community_host: String, + /// Mentioning event id. + pub event_id: Vec, + /// Mentioning event kind. + pub event_kind: i32, + /// Author-controlled event timestamp. + pub event_created_at: DateTime, + /// Attempt number, starting at one. + pub attempt: i32, +} + +/// Return whether an event kind should generate operator-listener notifications. +#[must_use] +pub const fn is_listener_mention_kind(kind: u32) -> bool { + let mut index = 0; + while index < OPERATOR_LISTENER_MENTION_KINDS.len() { + if OPERATOR_LISTENER_MENTION_KINDS[index] == kind { + return true; + } + index += 1; + } + false +} + +fn event_targets(event: &Event) -> Vec> { + let mut targets = Vec::new(); + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(String::as_str) != Some("p") { + continue; + } + let Some(value) = parts.get(1) else { + continue; + }; + let Ok(target) = hex::decode(value) else { + continue; + }; + if target.len() == 32 && !targets.iter().any(|known| known == &target) { + targets.push(target); + } + } + targets +} + +/// Insert one outbox row for each registered listener matching the event's +/// `p` tags. The caller owns the transaction and must commit it with the event. +pub(crate) async fn enqueue_mentions_in_transaction( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + event: &Event, +) -> Result { + let kind = u32::from(event.kind.as_u16()); + if !is_listener_mention_kind(kind) { + return Ok(0); + } + let targets = event_targets(event); + if targets.is_empty() { + return Ok(0); + } + let event_created_at = DateTime::from_timestamp(event.created_at.as_secs() as i64, 0).ok_or( + crate::error::DbError::InvalidTimestamp(event.created_at.as_secs() as i64), + )?; + + let mut query = QueryBuilder::::new( + "INSERT INTO operator_listener_outbox \ + (listener_pubkey, target_pubkey, community_id, event_id, event_kind, event_created_at) \ + SELECT r.listener_pubkey, r.target_pubkey, ", + ); + query + .push_bind(community_id.as_uuid()) + .push(", ") + .push_bind(event.id.as_bytes().as_slice()) + .push(", ") + .push_bind(kind as i32) + .push(", ") + .push_bind(event_created_at) + .push(" FROM operator_listener_pubkeys r WHERE r.target_pubkey IN ("); + let mut separated = query.separated(", "); + for target in &targets { + separated.push_bind(target.as_slice()); + } + separated.push_unseparated(") ON CONFLICT DO NOTHING"); + + Ok(query.build().execute(&mut **tx).await?.rows_affected()) +} + +/// Register target pubkeys for one deployment-global listener. +pub async fn register_pubkeys( + pool: &PgPool, + listener_pubkey: &[u8], + target_pubkeys: &[Vec], +) -> Result { + if target_pubkeys.is_empty() { + return Ok(0); + } + let mut seen = HashSet::with_capacity(target_pubkeys.len()); + let unique_target_pubkeys: Vec<&[u8]> = target_pubkeys + .iter() + .filter_map(|target| seen.insert(target.as_slice()).then_some(target.as_slice())) + .collect(); + let mut connection = acquire_maintenance_writer(pool).await?; + let mut query = QueryBuilder::::new( + "INSERT INTO operator_listener_pubkeys (listener_pubkey, target_pubkey) ", + ); + query.push_values(unique_target_pubkeys, |mut bind, target| { + bind.push_bind(listener_pubkey).push_bind(target); + }); + query.push( + " ON CONFLICT (listener_pubkey, target_pubkey) DO UPDATE SET created_at = EXCLUDED.created_at", + ); + Ok(query + .build() + .execute(&mut *connection) + .await? + .rows_affected()) +} + +/// Remove target pubkeys for one deployment-global listener. +pub async fn remove_pubkeys( + pool: &PgPool, + listener_pubkey: &[u8], + target_pubkeys: &[Vec], +) -> Result { + if target_pubkeys.is_empty() { + return Ok(0); + } + let mut connection = acquire_maintenance_writer(pool).await?; + let mut query = QueryBuilder::::new( + "DELETE FROM operator_listener_pubkeys WHERE listener_pubkey = ", + ); + query + .push_bind(listener_pubkey) + .push(" AND target_pubkey IN ("); + let mut separated = query.separated(", "); + for target in target_pubkeys { + separated.push_bind(target.as_slice()); + } + separated.push_unseparated(")"); + Ok(query + .build() + .execute(&mut *connection) + .await? + .rows_affected()) +} + +/// Delete registrations older than the operator-listener retention period. +pub async fn delete_expired_pubkeys(pool: &PgPool) -> Result { + let mut connection = acquire_maintenance_writer(pool).await?; + let cutoff = Utc::now() - PUBKEY_RETENTION; + Ok( + sqlx::query("DELETE FROM operator_listener_pubkeys WHERE created_at < $1") + .bind(cutoff) + .execute(&mut *connection) + .await? + .rows_affected(), + ) +} + +/// Claim due notification deliveries and recover claims whose visibility timeout expired. +pub async fn claim_deliveries( + pool: &PgPool, + limit: i64, + lease_until: DateTime, +) -> Result> { + let claim_id = Uuid::new_v4(); + let cutoff = Utc::now() - OUTBOX_RETENTION; + let mut connection = acquire_maintenance_writer(pool).await?; + let rows = sqlx::query( + "WITH candidates AS ( \ + SELECT o.id, o.community_id \ + FROM operator_listener_outbox o \ + WHERE o.attempts < $2 \ + AND o.next_attempt_at <= now() \ + AND o.created_at >= $4 \ + AND (o.state = 'pending' OR (o.state = 'sending' AND o.lease_until < now())) \ + ORDER BY o.next_attempt_at, o.created_at, o.id \ + FOR UPDATE OF o SKIP LOCKED \ + LIMIT $3 \ + ) \ + UPDATE operator_listener_outbox o \ + SET state = 'sending', claim_id = $1, lease_until = $5, attempts = o.attempts + 1 \ + FROM candidates c \ + JOIN communities community ON community.id = c.community_id \ + WHERE o.id = c.id \ + RETURNING o.id, o.claim_id, o.listener_pubkey, o.target_pubkey, o.community_id, \ + community.host, o.event_id, o.event_kind, o.event_created_at, o.attempts", + ) + .bind(claim_id) + .bind(MAX_DELIVERY_ATTEMPTS) + .bind(limit) + .bind(cutoff) + .bind(lease_until) + .fetch_all(&mut *connection) + .await?; + + rows.into_iter() + .map(|row| { + Ok(ClaimedDelivery { + id: row.try_get("id")?, + claim_id: row.try_get("claim_id")?, + listener_pubkey: row.try_get("listener_pubkey")?, + target_pubkey: row.try_get("target_pubkey")?, + community: CommunityId::from_uuid(row.try_get("community_id")?), + community_host: row.try_get("host")?, + event_id: row.try_get("event_id")?, + event_kind: row.try_get("event_kind")?, + event_created_at: row.try_get("event_created_at")?, + attempt: row.try_get("attempts")?, + }) + }) + .collect() +} + +/// Release a delivery claim that this pod cannot route. +pub async fn release_unroutable_delivery( + pool: &PgPool, + id: Uuid, + claim_id: Uuid, + next: DateTime, +) -> Result { + let mut connection = acquire_maintenance_writer(pool).await?; + Ok(sqlx::query( + "UPDATE operator_listener_outbox \ + SET state = 'pending', claim_id = NULL, lease_until = NULL, \ + next_attempt_at = $3, attempts = GREATEST(attempts - 1, 0) \ + WHERE id = $1 AND claim_id = $2 AND state = 'sending'", + ) + .bind(id) + .bind(claim_id) + .bind(next) + .execute(&mut *connection) + .await? + .rows_affected() + == 1) +} + +/// Mark one fenced delivery successful. +pub async fn complete_delivery(pool: &PgPool, id: Uuid, claim_id: Uuid) -> Result { + delete_claimed_delivery(pool, id, claim_id).await +} + +/// Retry one fenced delivery after a transient failure. +pub async fn retry_delivery( + pool: &PgPool, + id: Uuid, + claim_id: Uuid, + next: DateTime, +) -> Result { + let mut connection = acquire_maintenance_writer(pool).await?; + Ok(sqlx::query( + "UPDATE operator_listener_outbox \ + SET state = 'pending', claim_id = NULL, lease_until = NULL, next_attempt_at = $3 \ + WHERE id = $1 AND claim_id = $2 AND state = 'sending' AND attempts < $4", + ) + .bind(id) + .bind(claim_id) + .bind(next) + .bind(MAX_DELIVERY_ATTEMPTS) + .execute(&mut *connection) + .await? + .rows_affected() + == 1) +} + +/// Delete one fenced delivery after all delivery attempts fail. +pub async fn fail_delivery(pool: &PgPool, id: Uuid, claim_id: Uuid) -> Result { + delete_claimed_delivery(pool, id, claim_id).await +} + +async fn delete_claimed_delivery(pool: &PgPool, id: Uuid, claim_id: Uuid) -> Result { + let mut connection = acquire_maintenance_writer(pool).await?; + Ok(sqlx::query( + "DELETE FROM operator_listener_outbox \ + WHERE id = $1 AND claim_id = $2 AND state = 'sending'", + ) + .bind(id) + .bind(claim_id) + .execute(&mut *connection) + .await? + .rows_affected() + == 1) +} + +/// Delete delivery rows that have exceeded the outbox retention window. +pub async fn reap_deliveries(pool: &PgPool) -> Result { + let mut connection = acquire_maintenance_writer(pool).await?; + let cutoff = Utc::now() - OUTBOX_RETENTION; + Ok( + sqlx::query("DELETE FROM operator_listener_outbox WHERE created_at < $1") + .bind(cutoff) + .execute(&mut *connection) + .await? + .rows_affected(), + ) +} + +impl Db { + /// Register target pubkeys for one configured operator listener. + #[datastore_span(name = "register_operator_listener_pubkeys", system = "postgresql")] + pub async fn register_operator_listener_pubkeys( + &self, + listener_pubkey: &[u8], + target_pubkeys: &[Vec], + ) -> Result { + register_pubkeys(&self.pool, listener_pubkey, target_pubkeys).await + } + + /// Remove target pubkeys for one configured operator listener. + #[datastore_span(name = "remove_operator_listener_pubkeys", system = "postgresql")] + pub async fn remove_operator_listener_pubkeys( + &self, + listener_pubkey: &[u8], + target_pubkeys: &[Vec], + ) -> Result { + remove_pubkeys(&self.pool, listener_pubkey, target_pubkeys).await + } + + /// Claim due operator-listener deliveries. + #[datastore_span(name = "claim_operator_listener_deliveries", system = "postgresql")] + pub async fn claim_operator_listener_deliveries( + &self, + limit: i64, + lease_until: DateTime, + ) -> Result> { + claim_deliveries(&self.pool, limit, lease_until).await + } + + /// Mark one operator-listener delivery successful. + #[datastore_span(name = "complete_operator_listener_delivery", system = "postgresql")] + pub async fn complete_operator_listener_delivery( + &self, + id: Uuid, + claim_id: Uuid, + ) -> Result { + complete_delivery(&self.pool, id, claim_id).await + } + + /// Retry one operator-listener delivery. + #[datastore_span(name = "retry_operator_listener_delivery", system = "postgresql")] + pub async fn retry_operator_listener_delivery( + &self, + id: Uuid, + claim_id: Uuid, + next: DateTime, + ) -> Result { + retry_delivery(&self.pool, id, claim_id, next).await + } + + /// Release a delivery claim that this pod cannot route. + #[datastore_span(name = "release_operator_listener_delivery", system = "postgresql")] + pub async fn release_operator_listener_delivery( + &self, + id: Uuid, + claim_id: Uuid, + next: DateTime, + ) -> Result { + release_unroutable_delivery(&self.pool, id, claim_id, next).await + } + + /// Delete one operator-listener delivery after terminal failure. + #[datastore_span(name = "fail_operator_listener_delivery", system = "postgresql")] + pub async fn fail_operator_listener_delivery(&self, id: Uuid, claim_id: Uuid) -> Result { + fail_delivery(&self.pool, id, claim_id).await + } + + /// Reap stale deliveries. + #[datastore_span(name = "reap_operator_listener_deliveries", system = "postgresql")] + pub async fn reap_operator_listener_deliveries(&self) -> Result { + reap_deliveries(&self.pool).await + } + + /// Delete operator-listener registrations older than thirty days. + #[datastore_span( + name = "delete_expired_operator_listener_pubkeys", + system = "postgresql" + )] + pub async fn delete_expired_operator_listener_pubkeys(&self) -> Result { + delete_expired_pubkeys(&self.pool).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_message_kinds_are_listener_mentions() { + for kind in OPERATOR_LISTENER_MENTION_KINDS { + assert!(is_listener_mention_kind(kind)); + } + assert!(!is_listener_mention_kind(1)); + assert!(!is_listener_mention_kind(40003)); + } +} + +#[cfg(test)] +mod postgres_tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + use sqlx::PgPool; + + async fn setup_pool() -> PgPool { + PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect to test DB") + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn event_insert_enqueues_registered_listener_in_same_transaction() { + let pool = setup_pool().await; + let community_id = Uuid::new_v4(); + let listener = Keys::generate(); + let target = Keys::generate(); + let target_pubkey = target.public_key().to_bytes().to_vec(); + + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!( + "operator-listener-test-{}.example", + community_id.simple() + )) + .execute(&pool) + .await + .expect("insert test community"); + register_pubkeys( + &pool, + listener.public_key().as_bytes(), + std::slice::from_ref(&target_pubkey), + ) + .await + .expect("register listener target"); + + let event = EventBuilder::new(Kind::Custom(9), "mention") + .tag(Tag::public_key(target.public_key())) + .sign_with_keys(&Keys::generate()) + .expect("sign test event"); + let (_, inserted) = + crate::event::insert_event(&pool, CommunityId::from_uuid(community_id), &event, None) + .await + .expect("insert event"); + assert!(inserted); + + let outbox_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM operator_listener_outbox WHERE community_id = $1 AND event_id = $2", + ) + .bind(community_id) + .bind(event.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count listener deliveries"); + assert_eq!(outbox_count, 1); + + sqlx::query("DELETE FROM operator_listener_outbox WHERE community_id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test outbox rows"); + sqlx::query("DELETE FROM operator_listener_pubkeys WHERE listener_pubkey = $1") + .bind(listener.public_key().as_bytes().as_slice()) + .execute(&pool) + .await + .expect("delete test registration"); + sqlx::query("DELETE FROM event_mentions WHERE community_id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test mentions"); + sqlx::query("DELETE FROM events WHERE community_id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test event"); + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test community"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn thread_metadata_insert_fans_out_once_to_matching_listeners() { + let pool = setup_pool().await; + let community_id = Uuid::new_v4(); + let first_listener = Keys::generate(); + let second_listener = Keys::generate(); + let unrelated_listener = Keys::generate(); + let target = Keys::generate(); + let unrelated_target = Keys::generate(); + let target_pubkey = target.public_key().to_bytes().to_vec(); + let unrelated_pubkey = unrelated_target.public_key().to_bytes().to_vec(); + + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!( + "operator-listener-thread-test-{}.example", + community_id.simple() + )) + .execute(&pool) + .await + .expect("insert test community"); + for listener in [&first_listener, &second_listener] { + register_pubkeys( + &pool, + listener.public_key().as_bytes(), + std::slice::from_ref(&target_pubkey), + ) + .await + .expect("register matching listener target"); + } + register_pubkeys( + &pool, + unrelated_listener.public_key().as_bytes(), + std::slice::from_ref(&unrelated_pubkey), + ) + .await + .expect("register unrelated listener target"); + + let event = EventBuilder::new(Kind::Custom(9), "thread mention") + .tags([ + Tag::public_key(target.public_key()), + Tag::public_key(target.public_key()), + ]) + .sign_with_keys(&Keys::generate()) + .expect("sign test event"); + let community = CommunityId::from_uuid(community_id); + let (_, inserted) = + crate::event::insert_event_with_thread_metadata(&pool, community, &event, None, None) + .await + .expect("insert event through thread-metadata path"); + assert!(inserted); + + let delivered_listeners: Vec> = sqlx::query_scalar( + "SELECT listener_pubkey FROM operator_listener_outbox WHERE community_id = $1 AND event_id = $2 ORDER BY listener_pubkey", + ) + .bind(community_id) + .bind(event.id.as_bytes().as_slice()) + .fetch_all(&pool) + .await + .expect("read listener fanout"); + let mut expected_listeners = vec![ + first_listener.public_key().to_bytes().to_vec(), + second_listener.public_key().to_bytes().to_vec(), + ]; + expected_listeners.sort(); + assert_eq!(delivered_listeners, expected_listeners); + + let (_, duplicate_inserted) = + crate::event::insert_event_with_thread_metadata(&pool, community, &event, None, None) + .await + .expect("retry duplicate event through thread-metadata path"); + assert!(!duplicate_inserted); + let outbox_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM operator_listener_outbox WHERE community_id = $1 AND event_id = $2", + ) + .bind(community_id) + .bind(event.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count listener deliveries after duplicate event"); + assert_eq!(outbox_count, 2); + + sqlx::query("DELETE FROM operator_listener_outbox WHERE community_id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test outbox rows"); + sqlx::query("DELETE FROM operator_listener_pubkeys WHERE listener_pubkey IN ($1, $2, $3)") + .bind(first_listener.public_key().as_bytes().as_slice()) + .bind(second_listener.public_key().as_bytes().as_slice()) + .bind(unrelated_listener.public_key().as_bytes().as_slice()) + .execute(&pool) + .await + .expect("delete test registrations"); + sqlx::query("DELETE FROM events WHERE community_id = $1 AND id = $2") + .bind(community_id) + .bind(event.id.as_bytes().as_slice()) + .execute(&pool) + .await + .expect("delete test event"); + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test community"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn event_and_listener_outbox_roll_back_together() { + let pool = setup_pool().await; + let community_id = Uuid::new_v4(); + let listener = Keys::generate(); + let target = Keys::generate(); + let target_pubkey = target.public_key().to_bytes().to_vec(); + + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!( + "operator-listener-rollback-test-{}.example", + community_id.simple() + )) + .execute(&pool) + .await + .expect("insert test community"); + register_pubkeys( + &pool, + listener.public_key().as_bytes(), + std::slice::from_ref(&target_pubkey), + ) + .await + .expect("register listener target"); + let event = EventBuilder::new(Kind::Custom(9), "mention") + .tag(Tag::public_key(target.public_key())) + .sign_with_keys(&Keys::generate()) + .expect("sign test event"); + + let mut tx = pool.begin().await.expect("begin event transaction"); + let (_, inserted) = crate::event::insert_event_in_transaction( + &mut tx, + CommunityId::from_uuid(community_id), + &event, + None, + ) + .await + .expect("insert event and enqueue mention"); + assert!(inserted); + tx.rollback().await.expect("roll back event transaction"); + + let event_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM events WHERE community_id = $1 AND id = $2") + .bind(community_id) + .bind(event.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count rolled-back event"); + let outbox_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM operator_listener_outbox WHERE community_id = $1 AND event_id = $2", + ) + .bind(community_id) + .bind(event.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count rolled-back delivery"); + assert_eq!(event_count, 0); + assert_eq!(outbox_count, 0); + + sqlx::query("DELETE FROM operator_listener_pubkeys WHERE listener_pubkey = $1") + .bind(listener.public_key().as_bytes().as_slice()) + .execute(&pool) + .await + .expect("delete test registration"); + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test community"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn repeated_registration_renews_created_at() { + let pool = setup_pool().await; + let listener = Keys::generate(); + let target = Keys::generate(); + let listener_pubkey = listener.public_key().as_bytes().to_vec(); + let target_pubkey = target.public_key().as_bytes().to_vec(); + + register_pubkeys( + &pool, + &listener_pubkey, + std::slice::from_ref(&target_pubkey), + ) + .await + .expect("register listener target"); + + let expired_at = Utc::now() - PUBKEY_RETENTION - TimeDelta::hours(1); + sqlx::query( + "UPDATE operator_listener_pubkeys SET created_at = $3 \ + WHERE listener_pubkey = $1 AND target_pubkey = $2", + ) + .bind(&listener_pubkey) + .bind(&target_pubkey) + .bind(expired_at) + .execute(&pool) + .await + .expect("age test registration"); + + register_pubkeys( + &pool, + &listener_pubkey, + std::slice::from_ref(&target_pubkey), + ) + .await + .expect("renew listener target"); + + let renewed_at: DateTime = sqlx::query_scalar( + "SELECT created_at FROM operator_listener_pubkeys \ + WHERE listener_pubkey = $1 AND target_pubkey = $2", + ) + .bind(&listener_pubkey) + .bind(&target_pubkey) + .fetch_one(&pool) + .await + .expect("read renewed registration"); + assert!(renewed_at > expired_at); + + sqlx::query( + "DELETE FROM operator_listener_pubkeys \ + WHERE listener_pubkey = $1 AND target_pubkey = $2", + ) + .bind(&listener_pubkey) + .bind(&target_pubkey) + .execute(&pool) + .await + .expect("delete test registration"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn claim_deliveries_skips_rows_past_retention() { + let pool = setup_pool().await; + let community_id = Uuid::new_v4(); + let listener = Keys::generate(); + let target = Keys::generate(); + let stale_id = Uuid::new_v4(); + let fresh_id = Uuid::new_v4(); + let stale_at = Utc::now() - OUTBOX_RETENTION - TimeDelta::seconds(1); + let event_created_at = Utc::now(); + + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!( + "operator-listener-claim-test-{}.example", + community_id.simple() + )) + .execute(&pool) + .await + .expect("insert test community"); + for (id, event_marker, created_at) in [ + (stale_id, 1_u8, stale_at), + (fresh_id, 2_u8, event_created_at), + ] { + sqlx::query( + "INSERT INTO operator_listener_outbox \ + (id, listener_pubkey, target_pubkey, community_id, event_id, event_kind, \ + event_created_at, created_at) \ + VALUES ($1, $2, $3, $4, $5, 9, $6, $7)", + ) + .bind(id) + .bind(listener.public_key().as_bytes().as_slice()) + .bind(target.public_key().as_bytes().as_slice()) + .bind(community_id) + .bind(vec![event_marker; 32]) + .bind(event_created_at) + .bind(created_at) + .execute(&pool) + .await + .expect("insert test delivery"); + } + + let claimed = claim_deliveries(&pool, 1, Utc::now() + TimeDelta::seconds(30)) + .await + .expect("claim deliveries"); + assert_eq!(claimed.len(), 1); + assert_eq!(claimed[0].id, fresh_id); + + sqlx::query("DELETE FROM operator_listener_outbox WHERE community_id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test deliveries"); + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test community"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn expired_claim_is_recovered_and_old_claim_token_is_fenced() { + let pool = setup_pool().await; + let community_id = Uuid::new_v4(); + let listener = Keys::generate(); + let target = Keys::generate(); + let delivery_id = Uuid::new_v4(); + + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!( + "operator-listener-lease-test-{}.example", + community_id.simple() + )) + .execute(&pool) + .await + .expect("insert test community"); + sqlx::query( + "INSERT INTO operator_listener_outbox \ + (id, listener_pubkey, target_pubkey, community_id, event_id, event_kind, event_created_at) \ + VALUES ($1, $2, $3, $4, $5, 9, $6)", + ) + .bind(delivery_id) + .bind(listener.public_key().as_bytes().as_slice()) + .bind(target.public_key().as_bytes().as_slice()) + .bind(community_id) + .bind(vec![0x33_u8; 32]) + .bind(Utc::now()) + .execute(&pool) + .await + .expect("insert test delivery"); + + let first = claim_deliveries(&pool, 1, Utc::now() + TimeDelta::seconds(30)) + .await + .expect("claim delivery"); + assert_eq!(first.len(), 1); + assert_eq!(first[0].id, delivery_id); + assert!( + claim_deliveries(&pool, 1, Utc::now() + TimeDelta::seconds(30)) + .await + .expect("don't claim active lease") + .is_empty() + ); + + sqlx::query("UPDATE operator_listener_outbox SET lease_until = $2 WHERE id = $1") + .bind(delivery_id) + .bind(Utc::now() - TimeDelta::seconds(1)) + .execute(&pool) + .await + .expect("expire first lease"); + let recovered = claim_deliveries(&pool, 1, Utc::now() + TimeDelta::seconds(30)) + .await + .expect("recover expired claim"); + assert_eq!(recovered.len(), 1); + assert_eq!(recovered[0].id, delivery_id); + assert_ne!(recovered[0].claim_id, first[0].claim_id); + assert_eq!(recovered[0].attempt, first[0].attempt + 1); + assert!(!complete_delivery(&pool, delivery_id, first[0].claim_id) + .await + .expect("old claim cannot complete delivery")); + assert!(!retry_delivery( + &pool, + delivery_id, + first[0].claim_id, + Utc::now() + TimeDelta::seconds(10), + ) + .await + .expect("old claim cannot retry delivery")); + assert!(!release_unroutable_delivery( + &pool, + delivery_id, + first[0].claim_id, + Utc::now() + TimeDelta::seconds(10), + ) + .await + .expect("old claim cannot release delivery")); + assert!(!fail_delivery(&pool, delivery_id, first[0].claim_id) + .await + .expect("old claim cannot delete delivery")); + assert!(complete_delivery(&pool, delivery_id, recovered[0].claim_id) + .await + .expect("current claim completes delivery")); + + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test community"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn reapers_delete_expired_rows_and_preserve_recent_rows() { + let pool = setup_pool().await; + let listener = Keys::generate(); + let old_target = Keys::generate(); + let recent_target = Keys::generate(); + let listener_pubkey = listener.public_key().as_bytes().to_vec(); + let old_target_pubkey = old_target.public_key().as_bytes().to_vec(); + let recent_target_pubkey = recent_target.public_key().as_bytes().to_vec(); + + register_pubkeys( + &pool, + &listener_pubkey, + &[old_target_pubkey.clone(), recent_target_pubkey.clone()], + ) + .await + .expect("register test targets"); + let expired_at = Utc::now() - PUBKEY_RETENTION - TimeDelta::seconds(1); + sqlx::query( + "UPDATE operator_listener_pubkeys SET created_at = $3 \ + WHERE listener_pubkey = $1 AND target_pubkey = $2", + ) + .bind(&listener_pubkey) + .bind(&old_target_pubkey) + .bind(expired_at) + .execute(&pool) + .await + .expect("age test registration"); + + let community_id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!( + "operator-listener-reaper-test-{}.example", + community_id.simple() + )) + .execute(&pool) + .await + .expect("insert test community"); + let old_delivery = Uuid::new_v4(); + let recent_delivery = Uuid::new_v4(); + for (id, marker, created_at) in [ + ( + old_delivery, + 0x44_u8, + Utc::now() - OUTBOX_RETENTION - TimeDelta::seconds(1), + ), + (recent_delivery, 0x55_u8, Utc::now()), + ] { + sqlx::query( + "INSERT INTO operator_listener_outbox \ + (id, listener_pubkey, target_pubkey, community_id, event_id, event_kind, \ + event_created_at, created_at) \ + VALUES ($1, $2, $3, $4, $5, 9, $6, $7)", + ) + .bind(id) + .bind(&listener_pubkey) + .bind(&recent_target_pubkey) + .bind(community_id) + .bind(vec![marker; 32]) + .bind(Utc::now()) + .bind(created_at) + .execute(&pool) + .await + .expect("insert test delivery"); + } + + assert!( + delete_expired_pubkeys(&pool) + .await + .expect("reap expired registrations") + >= 1 + ); + assert!( + reap_deliveries(&pool) + .await + .expect("reap expired deliveries") + >= 1 + ); + + let registration_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM operator_listener_pubkeys \ + WHERE listener_pubkey = $1 AND target_pubkey = $2", + ) + .bind(&listener_pubkey) + .bind(&recent_target_pubkey) + .fetch_one(&pool) + .await + .expect("count recent registration"); + let old_delivery_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM operator_listener_outbox WHERE id = $1") + .bind(old_delivery) + .fetch_one(&pool) + .await + .expect("count expired delivery"); + let recent_delivery_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM operator_listener_outbox WHERE id = $1") + .bind(recent_delivery) + .fetch_one(&pool) + .await + .expect("count recent delivery"); + assert_eq!(registration_count, 1); + assert_eq!(old_delivery_count, 0); + assert_eq!(recent_delivery_count, 1); + + sqlx::query("DELETE FROM operator_listener_outbox WHERE community_id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test deliveries"); + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete test community"); + sqlx::query("DELETE FROM operator_listener_pubkeys WHERE listener_pubkey = $1") + .bind(&listener_pubkey) + .execute(&pool) + .await + .expect("delete test registrations"); + } +} diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index f238f2037b..1c912e599e 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -54,6 +54,44 @@ struct TransferCommunityResponse { const OPERATOR_REPLAY_SCOPE: &str = "operator-management"; +#[derive(Debug, Deserialize)] +struct ListenerPubkeysRequest { + pubkeys: Vec, +} + +fn parse_listener_pubkeys(body: &[u8]) -> Result>, (StatusCode, Json)> { + let request: ListenerPubkeysRequest = serde_json::from_slice(body).map_err(|e| { + api_error( + StatusCode::BAD_REQUEST, + &format!("invalid operator-listener pubkeys JSON: {e}"), + ) + })?; + if request.pubkeys.is_empty() || request.pubkeys.len() > 1_000 { + return Err(api_error( + StatusCode::BAD_REQUEST, + "pubkeys must contain between 1 and 1000 entries", + )); + } + request + .pubkeys + .into_iter() + .map(|value| { + let normalized = validate_pubkey_hex(&value).ok_or_else(|| { + api_error( + StatusCode::BAD_REQUEST, + "pubkeys must contain 64-char hex public keys", + ) + })?; + hex::decode(normalized).map_err(|_| { + api_error( + StatusCode::BAD_REQUEST, + "pubkeys must contain 64-char hex public keys", + ) + }) + }) + .collect() +} + /// Shared deployment-global operator auth prelude. The canonical management /// origin and replay namespace are configuration, never tenant registry state /// or an inbound proxy `Host` header. @@ -98,6 +136,86 @@ async fn authorize_operator_request( Ok(pubkey) } +/// Authenticate a deployment-global operator listener using its configured +/// identity and a NIP-98 request signature. +async fn authorize_operator_listener_request( + state: &Arc, + headers: &HeaderMap, + method: &str, + path: &str, + body: &[u8], +) -> Result)> { + let origin = state + .config + .relay_operator_api_origin + .as_deref() + .ok_or_else(|| internal_error("operator API origin is not configured"))?; + let url = format!("{origin}{path}"); + let bridge::VerifiedBridgeAuth { + pubkey, + event_id_bytes, + .. + } = bridge::verify_nip98_exempt_operator(headers, method, &url, Some(body))?; + check_operator_replay(state, event_id_bytes).await?; + if !state + .config + .operator_listener_delivery_urls + .contains_key(&pubkey.to_hex()) + { + return Err(api_error( + StatusCode::FORBIDDEN, + "actor not authorized: not a configured operator listener", + )); + } + Ok(pubkey) +} + +/// Register target pubkeys for the authenticated operator listener. +pub async fn register_listener_pubkeys( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let listener = authorize_operator_listener_request( + &state, + &headers, + "POST", + "/operator/listener/pubkeys", + &body, + ) + .await?; + let target_pubkeys = parse_listener_pubkeys(&body)?; + state + .db + .register_operator_listener_pubkeys(listener.as_bytes(), &target_pubkeys) + .await + .map_err(|e| internal_error(&format!("register operator-listener pubkeys: {e}")))?; + Ok(Json(serde_json::json!({}))) +} + +/// Remove target pubkeys for the authenticated operator listener. +pub async fn remove_listener_pubkeys( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let listener = authorize_operator_listener_request( + &state, + &headers, + "DELETE", + "/operator/listener/pubkeys", + &body, + ) + .await?; + let target_pubkeys = parse_listener_pubkeys(&body)?; + state + .db + .remove_operator_listener_pubkeys(listener.as_bytes(), &target_pubkeys) + .await + .map_err(|e| internal_error(&format!("remove operator-listener pubkeys: {e}")))?; + Ok(Json(serde_json::json!({}))) +} + async fn check_operator_replay( state: &AppState, event_id_bytes: [u8; 32], @@ -496,7 +614,10 @@ pub async fn community_availability( #[cfg(test)] mod postgres_tests { - use std::sync::Arc; + use std::{ + collections::HashSet, + sync::{Arc, Mutex}, + }; use axum::{ body::{to_bytes, Body}, @@ -529,6 +650,26 @@ mod postgres_tests { Box::pin(async { Ok(true) }) } } + + struct SeenOnceReplayGuard(Mutex>); + + impl buzz_auth::Nip98ReplayGuard for SeenOnceReplayGuard { + fn try_mark_in_scope<'a>( + &'a self, + _scope: &'a str, + event_id: &'a nostr::EventId, + _ttl_secs: u64, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + let inserted = self + .0 + .lock() + .expect("replay set") + .insert(*event_id.as_bytes()); + Box::pin(async move { Ok(inserted) }) + } + } const INGRESS_HOST: &str = "operator-ingress.example"; fn nip98_auth_header(keys: &Keys, url: &str, method: &str, body: Option<&[u8]>) -> String { @@ -629,6 +770,16 @@ mod postgres_tests { ) -> axum::response::Response { let url = format!("http://{INGRESS_HOST}{path}"); let auth = nip98_auth_header(keys, &url, method, body.as_deref().map(str::as_bytes)); + operator_request_with_auth(state, method, path, body, auth).await + } + + async fn operator_request_with_auth( + state: Arc, + method: &str, + path: &str, + body: Option, + auth: String, + ) -> axum::response::Response { let mut request = Request::builder() .method(method) .uri(path) @@ -770,6 +921,162 @@ mod postgres_tests { ); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn listener_pubkey_routes_register_and_remove_only_for_authenticated_listener() { + let listener = Keys::generate(); + let other_listener = Keys::generate(); + let outsider = Keys::generate(); + let Some(mut state) = operator_test_state(&[]).await else { + return; + }; + Arc::get_mut(&mut state) + .expect("test state has a single owner") + .nip98_replay = Arc::new(SeenOnceReplayGuard(Mutex::new(HashSet::new()))); + let config = Arc::make_mut( + &mut Arc::get_mut(&mut state) + .expect("test state has a single owner") + .config, + ); + config.operator_listener_delivery_urls.insert( + listener.public_key().to_hex(), + url::Url::parse("http://listener.example/deliver").expect("listener URL"), + ); + config.operator_listener_delivery_urls.insert( + other_listener.public_key().to_hex(), + url::Url::parse("http://other-listener.example/deliver").expect("listener URL"), + ); + + let targets = [Keys::generate(), Keys::generate()]; + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect to test DB"); + let target_hex = targets + .iter() + .map(|target| target.public_key().to_hex()) + .collect::>(); + let register_body = serde_json::json!({"pubkeys": target_hex}).to_string(); + let response = signed_operator_request( + Arc::clone(&state), + &listener, + "POST", + "/operator/listener/pubkeys", + Some(register_body), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + + let listener_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM operator_listener_pubkeys WHERE listener_pubkey = $1", + ) + .bind(listener.public_key().as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count registered listener targets"); + let other_listener_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM operator_listener_pubkeys WHERE listener_pubkey = $1", + ) + .bind(other_listener.public_key().as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count other listener targets"); + assert_eq!(listener_count, 2); + assert_eq!(other_listener_count, 0); + + let body = serde_json::json!({"pubkeys": [target_hex[0]]}).to_string(); + let missing_payload_auth = nip98_auth_header_without_payload( + &listener, + &format!("http://{INGRESS_HOST}/operator/listener/pubkeys"), + "DELETE", + ); + let missing_payload = operator_request_with_auth( + Arc::clone(&state), + "DELETE", + "/operator/listener/pubkeys", + Some(body), + missing_payload_auth, + ) + .await; + assert_eq!(missing_payload.status(), StatusCode::UNAUTHORIZED); + + let unauthorized = signed_operator_request( + Arc::clone(&state), + &outsider, + "POST", + "/operator/listener/pubkeys", + Some(serde_json::json!({"pubkeys": [target_hex[0]]}).to_string()), + ) + .await; + assert_eq!(unauthorized.status(), StatusCode::FORBIDDEN); + + let malformed = signed_operator_request( + Arc::clone(&state), + &listener, + "POST", + "/operator/listener/pubkeys", + Some(r#"{"pubkeys":["not-a-pubkey"]}"#.to_string()), + ) + .await; + assert_eq!(malformed.status(), StatusCode::BAD_REQUEST); + + let replay_body = serde_json::json!({"pubkeys": [target_hex[1]]}).to_string(); + let replay_auth = nip98_auth_header( + &listener, + &format!("http://{INGRESS_HOST}/operator/listener/pubkeys"), + "DELETE", + Some(replay_body.as_bytes()), + ); + let first_use = operator_request_with_auth( + Arc::clone(&state), + "DELETE", + "/operator/listener/pubkeys", + Some(replay_body.clone()), + replay_auth.clone(), + ) + .await; + assert_eq!(first_use.status(), StatusCode::OK); + let replay = operator_request_with_auth( + Arc::clone(&state), + "DELETE", + "/operator/listener/pubkeys", + Some(replay_body), + replay_auth, + ) + .await; + assert_eq!(replay.status(), StatusCode::UNAUTHORIZED); + let replay_error = read_json(replay).await; + assert!(replay_error["error"] + .as_str() + .unwrap_or_default() + .contains("replay")); + + let remove_body = serde_json::json!({"pubkeys": [target_hex[0]]}).to_string(); + let removed = signed_operator_request( + Arc::clone(&state), + &listener, + "DELETE", + "/operator/listener/pubkeys", + Some(remove_body), + ) + .await; + assert_eq!(removed.status(), StatusCode::OK); + let remaining: Vec> = sqlx::query_scalar( + "SELECT target_pubkey FROM operator_listener_pubkeys WHERE listener_pubkey = $1", + ) + .bind(listener.public_key().as_bytes().as_slice()) + .fetch_all(&pool) + .await + .expect("read remaining listener targets"); + assert!(remaining.is_empty()); + + sqlx::query("DELETE FROM operator_listener_pubkeys WHERE listener_pubkey = $1 OR listener_pubkey = $2") + .bind(listener.public_key().as_bytes().as_slice()) + .bind(other_listener.public_key().as_bytes().as_slice()) + .execute(&pool) + .await + .expect("clean up listener registrations"); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn unmapped_management_host_can_check_availability() { diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 20f6c68989..899b5422f2 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -1,11 +1,11 @@ //! Relay configuration from environment variables. -use std::net::SocketAddr; use std::time::Duration; +use std::{collections::HashMap, net::SocketAddr}; use sha2::{Digest, Sha256}; use thiserror::Error; -use tracing::warn; +use tracing::{error, warn}; /// Default maximum inbound WebSocket frame size in bytes. /// @@ -272,6 +272,9 @@ pub struct Config { /// skipped — a typo must not silently disable an operator. pub relay_operator_pubkeys: Vec, + /// Configured operator-listener identities and their HTTPS delivery URLs. + pub operator_listener_delivery_urls: HashMap, + /// Allow NIP-OA owner attestation for relay membership. /// /// When `true` and `require_relay_membership` is also `true`, agents @@ -350,8 +353,10 @@ pub struct Config { /// Required while push is enabled. An explicitly empty setting is allowed /// only while push is disabled. pub push_gateway_delivery_url: Option, - /// Hard timeout for one gateway delivery request. + /// Hard timeout for one push gateway delivery request. pub push_gateway_timeout: Duration, + /// Hard timeout for one operator-listener delivery request. + pub operator_listener_timeout: Duration, /// Optional relay-hosted policy shown on join surfaces. Disabled when no /// documents or age attestation are configured. @@ -478,6 +483,58 @@ fn parse_push_gateway_delivery_url(raw: &str) -> Result { Ok(url) } +fn parse_operator_listener_delivery_urls( + raw: &str, +) -> Result, ConfigError> { + let mut endpoints = HashMap::new(); + let entries: Vec<&str> = raw + .split(';') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .collect(); + if !raw.trim().is_empty() && entries.is_empty() { + return Err(ConfigError::InvalidValue( + "BUZZ_OPERATOR_LISTENERS must contain pubkey:delivery_url pairs".to_string(), + )); + } + for entry in entries { + let (pubkey, delivery_url) = entry.split_once(':').ok_or_else(|| { + ConfigError::InvalidValue( + "BUZZ_OPERATOR_LISTENERS entries must be pubkey:delivery_url pairs".to_string(), + ) + })?; + let pubkey = pubkey.trim().to_ascii_lowercase(); + if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_OPERATOR_LISTENERS entry has an invalid pubkey: {pubkey:?}" + ))); + } + let url = url::Url::parse(delivery_url.trim()).map_err(|e| { + ConfigError::InvalidValue(format!( + "BUZZ_OPERATOR_LISTENERS endpoint is not a valid URL: {e}" + )) + })?; + if url.scheme() != "https" + || url.host().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(ConfigError::InvalidValue( + "BUZZ_OPERATOR_LISTENERS endpoints must be HTTPS URLs without credentials, query, or fragment" + .to_string(), + )); + } + if endpoints.insert(pubkey.clone(), url).is_some() { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_OPERATOR_LISTENERS contains duplicate pubkey: {pubkey}" + ))); + } + } + Ok(endpoints) +} + fn parse_bool(name: &str, default: bool) -> Result { match std::env::var(name) { Err(std::env::VarError::NotPresent) => Ok(default), @@ -797,6 +854,32 @@ impl Config { ); } + let operator_listener_delivery_urls = match std::env::var("BUZZ_OPERATOR_LISTENERS") { + Ok(raw) => match parse_operator_listener_delivery_urls(&raw) { + Ok(urls) => urls, + Err(parse_error) => { + error!( + error = %parse_error, + "invalid BUZZ_OPERATOR_LISTENERS; operator-listener mention delivery is disabled" + ); + HashMap::new() + } + }, + Err(std::env::VarError::NotPresent) => HashMap::new(), + Err(error) => { + error!( + error = %error, + "BUZZ_OPERATOR_LISTENERS must be valid UTF-8; operator-listener mention delivery is disabled" + ); + HashMap::new() + } + }; + if !operator_listener_delivery_urls.is_empty() && relay_operator_api_origin.is_none() { + error!( + "BUZZ_OPERATOR_LISTENERS is set but RELAY_OPERATOR_API_ORIGIN is not — operator-listener registration requests will reject every request until RELAY_OPERATOR_API_ORIGIN is set" + ); + } + let auth = buzz_auth::AuthConfig { rate_limits: rate_limit_config_from_env()?, }; @@ -1012,6 +1095,33 @@ impl Config { Err(_) => 2_000, }; let push_gateway_timeout = Duration::from_millis(push_gateway_timeout_millis); + let operator_listener_timeout_millis = match std::env::var( + "BUZZ_OPERATOR_LISTENER_TIMEOUT_MS", + ) { + Ok(raw) => match raw + .parse::() + .ok() + .filter(|millis| (100..=10_000).contains(millis)) + { + Some(millis) => millis, + None => { + error!( + value = %raw, + "invalid BUZZ_OPERATOR_LISTENER_TIMEOUT_MS; using default 5000ms (expected integer in 100..=10000)" + ); + 5_000 + } + }, + Err(std::env::VarError::NotPresent) => 5_000, + Err(err) => { + error!( + error = %err, + "invalid BUZZ_OPERATOR_LISTENER_TIMEOUT_MS; using default 5000ms" + ); + 5_000 + } + }; + let operator_listener_timeout = Duration::from_millis(operator_listener_timeout_millis); const MAX_POLICY_MARKDOWN_BYTES: usize = 256 * 1024; let read_policy_markdown = |name: &str| -> Result, ConfigError> { @@ -1248,6 +1358,7 @@ impl Config { relay_owner_pubkey, relay_operator_api_origin, relay_operator_pubkeys, + operator_listener_delivery_urls, allow_nip_oa_auth, klipy, media, @@ -1269,6 +1380,7 @@ impl Config { push_executor_key_id, push_gateway_delivery_url, push_gateway_timeout, + operator_listener_timeout, join_policy, admin, web_dir, @@ -1430,14 +1542,8 @@ mod tests { config } - /// Like `config_with_admin_env`, but also captures the tracing output - /// emitted during `Config::from_env()` so a test can assert the startup - /// warning fired. The `BUZZ_ADMIN_TOKEN` warning is the sole behavioral - /// value of retaining the guards (the variable is otherwise inert), so it - /// must be regression-protected: deleting a warn block has to fail a test. - fn config_with_admin_env_capturing_logs( - values: &[(&str, Option<&str>)], - ) -> (Result, String) { + /// Capture the tracing output emitted during `Config::from_env()`. + fn capture_config_logs() -> (Result, String) { use std::sync::{Arc, Mutex}; #[derive(Clone)] @@ -1472,12 +1578,56 @@ mod tests { }) .with_ansi(false) .finish(); - let config = - tracing::subscriber::with_default(subscriber, || config_with_admin_env(values)); + let config = tracing::subscriber::with_default(subscriber, Config::from_env); let captured = String::from_utf8(buf.lock().unwrap().clone()).unwrap_or_default(); (config, captured) } + /// Like `config_with_admin_env`, but also captures the tracing output + /// emitted during `Config::from_env()` so a test can assert the startup + /// warning fired. The `BUZZ_ADMIN_TOKEN` warning is the sole behavioral + /// value of retaining the guards (the variable is otherwise inert), so it + /// must be regression-protected: deleting a warn block has to fail a test. + fn config_with_admin_env_capturing_logs( + values: &[(&str, Option<&str>)], + ) -> (Result, String) { + const KEYS: [&str; 3] = ["BUZZ_ADMIN_HOST", "BUZZ_ADMIN_TOKEN", "BUZZ_ADMIN_AUTH"]; + let previous: Vec<_> = KEYS + .iter() + .map(|key| (*key, std::env::var_os(key))) + .collect(); + for key in KEYS { + std::env::remove_var(key); + } + for (key, value) in values { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + let result = capture_config_logs(); + for (key, value) in previous { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + result + } + + fn config_with_operator_listeners_capturing_logs( + raw: &str, + ) -> (Result, String) { + let previous = std::env::var_os("BUZZ_OPERATOR_LISTENERS"); + std::env::set_var("BUZZ_OPERATOR_LISTENERS", raw); + let result = capture_config_logs(); + match previous { + Some(value) => std::env::set_var("BUZZ_OPERATOR_LISTENERS", value), + None => std::env::remove_var("BUZZ_OPERATOR_LISTENERS"), + } + result + } + /// Assert `captured` contains a WARN naming the removal of `BUZZ_ADMIN_TOKEN` /// so the migration breadcrumb Will's ruling preserved cannot silently regress. fn assert_admin_token_removal_warning(captured: &str) { @@ -2293,6 +2443,52 @@ mod tests { } } + #[test] + fn operator_listener_routes_parse_and_normalize() { + let routes = parse_operator_listener_delivery_urls( + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:https://one.example/mentions;bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:https://two.example/mentions", + ) + .expect("valid operator-listener routes"); + assert_eq!(routes.len(), 2); + assert!( + routes.contains_key("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + ); + assert!( + routes.contains_key("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + ); + } + + #[test] + fn operator_listener_routes_reject_unsafe_or_duplicate_entries() { + let key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + for raw in [ + format!("{key}:http://listener.example/mentions"), + format!("{key}:https://user:pass@listener.example/mentions"), + format!("{key}:https://listener.example/mentions?token=secret"), + format!("{key}:https://listener.example/mentions;{key}:https://other.example"), + ] { + assert!( + parse_operator_listener_delivery_urls(&raw).is_err(), + "unsafe or duplicate route accepted: {raw}" + ); + } + } + + #[test] + fn malformed_operator_listener_config_is_logged_and_disables_delivery() { + let _guard = ENV_MUTEX.lock().unwrap(); + let (result, logs) = config_with_operator_listeners_capturing_logs(";"); + let config = result.expect("malformed listener config must not stop relay startup"); + + assert!(config.operator_listener_delivery_urls.is_empty()); + assert!(logs.contains("ERROR"), "expected an ERROR line: {logs:?}"); + assert!(logs.contains("BUZZ_OPERATOR_LISTENERS"), "logs: {logs:?}"); + assert!( + logs.contains("operator-listener mention delivery is disabled"), + "logs: {logs:?}" + ); + } + #[test] fn invalid_push_gateway_timeout_is_not_silently_defaulted() { let _guard = ENV_MUTEX.lock().unwrap(); diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index ddec559617..6991ebd368 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -33,11 +33,14 @@ pub mod mesh_boot; pub mod metrics; /// NIP-11 relay information document. pub mod nip11; +mod nip98; /// NIP-FI relay configuration: mode, issuer registry, JWKS warm/refresh. pub mod nip_fi_config; /// NIP-FI HTTP ingress enforcement: assertion extraction, verification, /// key-pairing check, and deny-map gate for every protected HTTP surface. pub(crate) mod nip_fi_http; +/// Deployment-global operator-listener mention delivery worker. +pub mod operator_listener; /// NIP-01 client/relay message parsing. pub mod protocol; /// Durable NIP-PL matcher and delivery worker. diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 8211dcbacb..6adea419da 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -857,6 +857,24 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { info!("NIP-PL push disabled by BUZZ_PUSH_ENABLED"); } + // Registration cleanup is independent of configured routes. Delivery is + // opt-in, while accepted events populate the outbox transactionally. + tokio::spawn(buzz_relay::operator_listener::run_reaper(Arc::clone( + &state, + ))); + if !state.config.operator_listener_delivery_urls.is_empty() { + tokio::spawn(buzz_relay::operator_listener::run_delivery_worker( + Arc::clone(&state), + )); + info!( + listeners = state.config.operator_listener_delivery_urls.len(), + "operator-listener mention delivery worker started" + ); + } else { + info!("operator-listener mention delivery disabled by BUZZ_OPERATOR_LISTENERS"); + } + info!("operator-listener registration cleanup started"); + // Admin outbox delivery worker — drives `relay_admin_outbox` rows. // Uses DB-level leases (held_by / lease_expires_at) so multiple pods can // run the worker concurrently without double-delivery. diff --git a/crates/buzz-relay/src/nip98.rs b/crates/buzz-relay/src/nip98.rs new file mode 100644 index 0000000000..117131396d --- /dev/null +++ b/crates/buzz-relay/src/nip98.rs @@ -0,0 +1,22 @@ +//! Shared NIP-98 signing helpers for relay-originated HTTP requests. + +use base64::Engine as _; +use nostr::{EventBuilder, Kind, Tag}; +use sha2::{Digest as _, Sha256}; + +/// Build a NIP-98 authorization header for a signed JSON `POST` request. +pub(crate) fn nip98_header(keys: &nostr::Keys, url: &str, body: &[u8]) -> anyhow::Result { + let hash = hex::encode(Sha256::digest(body)); + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags([ + Tag::parse(["u", url])?, + Tag::parse(["method", "POST"])?, + Tag::parse(["payload", &hash])?, + Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()])?, + ]) + .sign_with_keys(keys)?; + Ok(format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&event)?) + )) +} diff --git a/crates/buzz-relay/src/operator_listener.rs b/crates/buzz-relay/src/operator_listener.rs new file mode 100644 index 0000000000..a21292d2ae --- /dev/null +++ b/crates/buzz-relay/src/operator_listener.rs @@ -0,0 +1,776 @@ +//! Deployment-global operator-listener mention matching and notification delivery. + +use std::{collections::HashMap, sync::Arc, time::Duration}; + +use chrono::{DateTime, TimeDelta, Utc}; +use futures_util::future::join_all; +use serde::Serialize; +use tracing::{error, warn}; +use uuid::Uuid; + +use crate::{nip98::nip98_header, state::AppState}; + +use reqwest::StatusCode; + +const CLAIM_SECS: i64 = 30; +const DELIVERY_BATCH_LIMIT: i64 = 10; +const IDLE_POLL_FLOOR: Duration = Duration::from_millis(250); +const IDLE_POLL_CEILING: Duration = Duration::from_secs(2); +const OUTBOX_REAP_INTERVAL: Duration = Duration::from_secs(5 * 60); +const REGISTRATION_REAP_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); + +/// Notification sent to the configured operator-listener endpoint. +#[derive(Debug, Serialize)] +struct MentionNotification { + v: u8, + pubkey: String, + community_host: String, + event_id: String, + event_kind: i32, + event_created_at: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WorkerIteration { + Worked, + Idle, + Failed, +} + +/// Reap stale deliveries every five minutes and expired registrations daily. +pub async fn run_reaper(state: Arc) { + tokio::join!( + async { + loop { + if let Err(error) = state.db.delete_expired_operator_listener_pubkeys().await { + warn!(%error, "operator-listener registration cleanup failed"); + } + tokio::time::sleep(REGISTRATION_REAP_INTERVAL).await; + } + }, + async { + loop { + if let Err(error) = state.db.reap_operator_listener_deliveries().await { + warn!(%error, "operator-listener outbox reap failed"); + } + tokio::time::sleep(OUTBOX_REAP_INTERVAL).await; + } + } + ); +} + +/// Continuously deliver operator-listener notification rows with bounded concurrency and retries. +pub async fn run_delivery_worker(state: Arc) { + let http = match build_delivery_http_client(state.config.operator_listener_timeout) { + Ok(http) => http, + Err(error) => { + error!(%error, "operator-listener HTTP client initialization failed"); + return; + } + }; + let mut idle_delay = IDLE_POLL_FLOOR; + loop { + match run_delivery_once(&state, &http).await { + WorkerIteration::Worked => idle_delay = IDLE_POLL_FLOOR, + WorkerIteration::Idle => { + tokio::time::sleep(idle_delay).await; + idle_delay = (idle_delay * 2).min(IDLE_POLL_CEILING); + } + WorkerIteration::Failed => tokio::time::sleep(Duration::from_secs(2)).await, + } + } +} + +fn build_delivery_http_client(timeout: Duration) -> Result { + reqwest::Client::builder() + .timeout(timeout) + .redirect(reqwest::redirect::Policy::none()) + .build() +} + +async fn run_delivery_once(state: &AppState, http: &reqwest::Client) -> WorkerIteration { + let transport = ReqwestDeliveryTransport { http }; + run_delivery_once_with_transport(state, &transport).await +} + +#[async_trait::async_trait] +trait DeliveryTransport: Sync { + async fn post( + &self, + url: &url::Url, + authorization: &str, + body: Vec, + ) -> Result; +} + +#[async_trait::async_trait] +trait DeliveryStore: Sync { + async fn release(&self, id: Uuid, claim_id: Uuid, next: DateTime) -> Result; + async fn complete(&self, id: Uuid, claim_id: Uuid) -> Result; + async fn retry(&self, id: Uuid, claim_id: Uuid, next: DateTime) -> Result; + async fn fail(&self, id: Uuid, claim_id: Uuid) -> Result; +} + +struct DbDeliveryStore<'a> { + db: &'a buzz_db::Db, +} + +#[async_trait::async_trait] +impl DeliveryStore for DbDeliveryStore<'_> { + async fn release(&self, id: Uuid, claim_id: Uuid, next: DateTime) -> Result { + self.db + .release_operator_listener_delivery(id, claim_id, next) + .await + .map_err(|error| error.to_string()) + } + + async fn complete(&self, id: Uuid, claim_id: Uuid) -> Result { + self.db + .complete_operator_listener_delivery(id, claim_id) + .await + .map_err(|error| error.to_string()) + } + + async fn retry(&self, id: Uuid, claim_id: Uuid, next: DateTime) -> Result { + self.db + .retry_operator_listener_delivery(id, claim_id, next) + .await + .map_err(|error| error.to_string()) + } + + async fn fail(&self, id: Uuid, claim_id: Uuid) -> Result { + self.db + .fail_operator_listener_delivery(id, claim_id) + .await + .map_err(|error| error.to_string()) + } +} + +struct ReqwestDeliveryTransport<'a> { + http: &'a reqwest::Client, +} + +#[async_trait::async_trait] +impl DeliveryTransport for ReqwestDeliveryTransport<'_> { + async fn post( + &self, + url: &url::Url, + authorization: &str, + body: Vec, + ) -> Result { + self.http + .post(url.clone()) + .header("Authorization", authorization) + .header("Content-Type", "application/json") + .body(body) + .send() + .await + .map(|response| response.status()) + .map_err(|error| error.to_string()) + } +} + +async fn run_delivery_once_with_transport( + state: &AppState, + transport: &T, +) -> WorkerIteration { + let claimed = match state + .db + .claim_operator_listener_deliveries( + DELIVERY_BATCH_LIMIT, + Utc::now() + TimeDelta::seconds(CLAIM_SECS), + ) + .await + { + Ok(claimed) => claimed, + Err(error) => { + error!(%error, "operator-listener delivery claim failed"); + return WorkerIteration::Failed; + } + }; + if claimed.is_empty() { + return WorkerIteration::Idle; + } + let store = DbDeliveryStore { db: &state.db }; + join_all(claimed.into_iter().map(|delivery| { + deliver_one( + &state.config.operator_listener_delivery_urls, + &state.relay_keypair, + &store, + transport, + delivery, + ) + })) + .await; + WorkerIteration::Worked +} + +async fn deliver_one( + routes: &HashMap, + relay_keypair: &nostr::Keys, + store: &S, + transport: &T, + delivery: buzz_db::operator_listener::ClaimedDelivery, +) { + let listener_hex = hex::encode(&delivery.listener_pubkey); + let Some(url) = routes.get(&listener_hex) else { + warn!( + delivery=%delivery.id, + listener=%listener_hex, + "operator-listener delivery has no route on this pod; releasing claim" + ); + if let Err(error) = store + .release( + delivery.id, + delivery.claim_id, + Utc::now() + TimeDelta::seconds(CLAIM_SECS), + ) + .await + { + error!(%error, delivery=%delivery.id, "failed to release unroutable operator-listener delivery"); + } + metrics::counter!("buzz_operator_listener_deliveries_total", "outcome" => "unroutable") + .increment(1); + return; + }; + let body = match serde_json::to_vec(&MentionNotification { + v: 1, + pubkey: hex::encode(&delivery.target_pubkey), + community_host: delivery.community_host.clone(), + event_id: hex::encode(&delivery.event_id), + event_kind: delivery.event_kind, + event_created_at: delivery.event_created_at.timestamp(), + }) { + Ok(body) => body, + Err(error) => { + fail_permanently( + store, + &delivery, + &format!("notification encoding failed: {error}"), + ) + .await; + return; + } + }; + let auth = match nip98_header(relay_keypair, url.as_str(), &body) { + Ok(auth) => auth, + Err(error) => { + fail_permanently( + store, + &delivery, + &format!("notification auth failed: {error}"), + ) + .await; + return; + } + }; + let response = transport.post(url, &auth, body).await; + match response { + Ok(status) if status.is_success() => { + match store.complete(delivery.id, delivery.claim_id).await { + Ok(true) => {} + Ok(false) => warn!( + delivery=%delivery.id, + "operator-listener delivery completion lost its claim" + ), + Err(error) => error!( + delivery=%delivery.id, + %error, + "failed to persist operator-listener delivery completion" + ), + } + metrics::counter!("buzz_operator_listener_deliveries_total", "outcome" => "accepted") + .increment(1); + } + Ok(status) + if status == StatusCode::REQUEST_TIMEOUT + || status == StatusCode::TOO_MANY_REQUESTS + || status.is_server_error() => + { + retry_or_fail(store, &delivery, format!("HTTP {status}")).await + } + Ok(status) => { + fail_permanently(store, &delivery, &format!("HTTP {status}")).await; + } + Err(error) => retry_or_fail(store, &delivery, error.to_string()).await, + } +} + +async fn fail_permanently( + store: &S, + delivery: &buzz_db::operator_listener::ClaimedDelivery, + reason: &str, +) { + error!(delivery=%delivery.id, %reason, "operator-listener delivery failed permanently"); + if let Err(error) = store.fail(delivery.id, delivery.claim_id).await { + error!(delivery=%delivery.id, %error, "failed to delete terminal operator-listener delivery"); + } + metrics::counter!("buzz_operator_listener_deliveries_total", "outcome" => "failed") + .increment(1); +} + +async fn retry_or_fail( + store: &S, + delivery: &buzz_db::operator_listener::ClaimedDelivery, + reason: String, +) { + if delivery.attempt >= buzz_db::operator_listener::MAX_DELIVERY_ATTEMPTS { + fail_permanently(store, delivery, &format!("retries exhausted: {reason}")).await; + return; + } + let delay = 2_i64.pow((delivery.attempt - 1).clamp(0, 7) as u32); + warn!( + delivery=%delivery.id, + attempt=delivery.attempt, + retry_in_seconds=delay, + %reason, + "operator-listener delivery failed; retrying" + ); + if let Err(error) = store + .retry( + delivery.id, + delivery.claim_id, + Utc::now() + TimeDelta::seconds(delay), + ) + .await + { + error!(delivery=%delivery.id, %error, "failed to persist operator-listener retry"); + } + metrics::counter!("buzz_operator_listener_deliveries_total", "outcome" => "retry").increment(1); +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use tokio::{ + io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}, + net::{TcpListener, TcpStream}, + }; + + use super::*; + use buzz_core::tenant::CommunityId; + use nostr::Keys; + + #[test] + fn reapers_use_their_expected_intervals() { + assert_eq!(OUTBOX_REAP_INTERVAL, Duration::from_secs(5 * 60)); + assert_eq!( + REGISTRATION_REAP_INTERVAL, + Duration::from_secs(24 * 60 * 60) + ); + } + + #[derive(Debug, Clone, PartialEq, Eq)] + enum StoreCall { + Release(Uuid, Uuid, DateTime), + Complete(Uuid, Uuid), + Retry(Uuid, Uuid, DateTime), + Fail(Uuid, Uuid), + } + + #[derive(Default)] + struct MockStore { + calls: Mutex>, + } + + impl MockStore { + fn calls(&self) -> Vec { + self.calls.lock().expect("store calls lock").clone() + } + } + + #[async_trait::async_trait] + impl DeliveryStore for MockStore { + async fn release( + &self, + id: Uuid, + claim_id: Uuid, + next: DateTime, + ) -> Result { + self.calls + .lock() + .expect("store calls lock") + .push(StoreCall::Release(id, claim_id, next)); + Ok(true) + } + + async fn complete(&self, id: Uuid, claim_id: Uuid) -> Result { + self.calls + .lock() + .expect("store calls lock") + .push(StoreCall::Complete(id, claim_id)); + Ok(true) + } + + async fn retry( + &self, + id: Uuid, + claim_id: Uuid, + next: DateTime, + ) -> Result { + self.calls + .lock() + .expect("store calls lock") + .push(StoreCall::Retry(id, claim_id, next)); + Ok(true) + } + + async fn fail(&self, id: Uuid, claim_id: Uuid) -> Result { + self.calls + .lock() + .expect("store calls lock") + .push(StoreCall::Fail(id, claim_id)); + Ok(true) + } + } + + struct SentRequest { + url: url::Url, + authorization: String, + body: Vec, + } + + struct MockTransport { + response: Mutex>>, + request: Mutex>, + request_count: Mutex, + } + + impl MockTransport { + fn new(response: Result) -> Self { + Self { + response: Mutex::new(Some(response)), + request: Mutex::new(None), + request_count: Mutex::new(0), + } + } + + fn take_request(&self) -> Option { + self.request.lock().expect("transport request lock").take() + } + + fn request_count(&self) -> usize { + *self + .request_count + .lock() + .expect("transport request count lock") + } + } + + #[async_trait::async_trait] + impl DeliveryTransport for MockTransport { + async fn post( + &self, + url: &url::Url, + authorization: &str, + body: Vec, + ) -> Result { + *self + .request_count + .lock() + .expect("transport request count lock") += 1; + *self.request.lock().expect("transport request lock") = Some(SentRequest { + url: url.clone(), + authorization: authorization.to_owned(), + body, + }); + self.response + .lock() + .expect("transport response lock") + .take() + .expect("delivery should make one request") + } + } + + fn delivery_fixture( + attempt: i32, + ) -> ( + HashMap, + Keys, + buzz_db::operator_listener::ClaimedDelivery, + ) { + let listener = Keys::generate(); + let listener_pubkey = listener.public_key().to_bytes().to_vec(); + let route = url::Url::parse("https://listener.example/mentions").expect("test URL"); + let routes = HashMap::from([(hex::encode(&listener_pubkey), route)]); + ( + routes, + Keys::generate(), + buzz_db::operator_listener::ClaimedDelivery { + id: Uuid::new_v4(), + claim_id: Uuid::new_v4(), + listener_pubkey, + target_pubkey: vec![0x11; 32], + community: CommunityId::from_uuid(Uuid::new_v4()), + community_host: "community.example".to_owned(), + event_id: vec![0x22; 32], + event_kind: 9, + event_created_at: DateTime::from_timestamp(1_700_000_000, 0) + .expect("test timestamp"), + attempt, + }, + ) + } + + #[tokio::test] + async fn unroutable_delivery_releases_claim_without_http_request() { + let (_, relay_keypair, delivery) = delivery_fixture(1); + let store = MockStore::default(); + let transport = MockTransport::new(Ok(StatusCode::NO_CONTENT)); + let before = Utc::now() + TimeDelta::seconds(CLAIM_SECS); + + deliver_one( + &HashMap::new(), + &relay_keypair, + &store, + &transport, + delivery.clone(), + ) + .await; + + let after = Utc::now() + TimeDelta::seconds(CLAIM_SECS); + assert!(transport.take_request().is_none()); + let calls = store.calls(); + assert_eq!(calls.len(), 1); + match &calls[0] { + StoreCall::Release(id, claim_id, next) => { + assert_eq!(*id, delivery.id); + assert_eq!(*claim_id, delivery.claim_id); + assert!((before..=after).contains(next)); + } + call => panic!("expected claim release, got {call:?}"), + } + } + + #[tokio::test] + async fn successful_delivery_posts_payload_and_completes_claim() { + let (routes, relay_keypair, delivery) = delivery_fixture(1); + let store = MockStore::default(); + let transport = MockTransport::new(Ok(StatusCode::NO_CONTENT)); + + deliver_one( + &routes, + &relay_keypair, + &store, + &transport, + delivery.clone(), + ) + .await; + + let request = transport.take_request().expect("delivery request"); + assert_eq!(request.url, routes[&hex::encode(&delivery.listener_pubkey)]); + assert!(request.authorization.starts_with("Nostr ")); + let body: serde_json::Value = serde_json::from_slice(&request.body).expect("JSON body"); + assert_eq!(body["v"], 1); + assert_eq!(body["pubkey"], hex::encode(&delivery.target_pubkey)); + assert_eq!(body["community_host"], delivery.community_host); + assert_eq!(body["event_id"], hex::encode(&delivery.event_id)); + assert_eq!(body["event_kind"], delivery.event_kind); + assert_eq!( + body["event_created_at"], + delivery.event_created_at.timestamp() + ); + assert_eq!( + store.calls(), + [StoreCall::Complete(delivery.id, delivery.claim_id)] + ); + } + + #[tokio::test] + async fn rate_limit_and_server_errors_retry_with_exponential_delay() { + for status in [ + StatusCode::TOO_MANY_REQUESTS, + StatusCode::SERVICE_UNAVAILABLE, + ] { + let (routes, relay_keypair, delivery) = delivery_fixture(3); + let store = MockStore::default(); + let transport = MockTransport::new(Ok(status)); + let before = Utc::now() + TimeDelta::seconds(4); + + deliver_one( + &routes, + &relay_keypair, + &store, + &transport, + delivery.clone(), + ) + .await; + + let after = Utc::now() + TimeDelta::seconds(4); + assert_eq!(transport.request_count(), 1, "HTTP {status}"); + assert!(transport.take_request().is_some()); + let calls = store.calls(); + assert_eq!(calls.len(), 1, "HTTP {status}"); + match &calls[0] { + StoreCall::Retry(id, claim_id, next) => { + assert_eq!(*id, delivery.id); + assert_eq!(*claim_id, delivery.claim_id); + assert!((before..=after).contains(next)); + } + call => panic!("expected HTTP {status} retry, got {call:?}"), + } + } + } + + #[tokio::test] + async fn deterministic_client_error_fails_without_retrying() { + let (routes, relay_keypair, delivery) = delivery_fixture(1); + let store = MockStore::default(); + let transport = MockTransport::new(Ok(StatusCode::BAD_REQUEST)); + + deliver_one( + &routes, + &relay_keypair, + &store, + &transport, + delivery.clone(), + ) + .await; + + assert_eq!(transport.request_count(), 1); + assert!(transport.take_request().is_some()); + assert_eq!( + store.calls(), + [StoreCall::Fail(delivery.id, delivery.claim_id)] + ); + } + + async fn read_http_request(stream: &mut TcpStream) -> (String, Vec) { + let mut reader = BufReader::new(stream); + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .await + .expect("read request line"); + + let mut content_length = 0; + loop { + let mut line = String::new(); + reader.read_line(&mut line).await.expect("read header"); + if line == "\r\n" || line.is_empty() { + break; + } + if let Some((name, value)) = line.split_once(':') { + if name.eq_ignore_ascii_case("content-length") { + content_length = value.trim().parse().expect("valid content length"); + } + } + } + + let mut body = vec![0; content_length]; + reader + .read_exact(&mut body) + .await + .expect("read request body"); + (request_line, body) + } + + #[tokio::test] + async fn redirect_response_fails_delivery_without_retrying() { + let source = TcpListener::bind("127.0.0.1:0") + .await + .expect("source listener"); + let source_addr = source.local_addr().expect("source address"); + let target = TcpListener::bind("127.0.0.1:0") + .await + .expect("target listener"); + let target_addr = target.local_addr().expect("target address"); + let target_url = format!("http://{target_addr}/redirected"); + let target_task = tokio::spawn(async move { + match tokio::time::timeout(Duration::from_secs(1), target.accept()).await { + Ok(Ok((mut stream, _))) => { + let request = read_http_request(&mut stream).await; + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await + .expect("write target success response"); + Some(request) + } + Ok(Err(error)) => panic!("accept target request: {error}"), + Err(_) => None, + } + }); + let (_, relay_keypair, delivery) = delivery_fixture(1); + let source_url = + url::Url::parse(&format!("http://{source_addr}/mentions")).expect("source URL"); + let routes = HashMap::from([(hex::encode(&delivery.listener_pubkey), source_url)]); + let store = MockStore::default(); + let http = build_delivery_http_client(Duration::from_secs(2)).expect("delivery client"); + let transport = ReqwestDeliveryTransport { http: &http }; + + let source_task = tokio::spawn(async move { + let (mut stream, _) = source.accept().await.expect("accept source request"); + let request = read_http_request(&mut stream).await; + let response = format!( + "HTTP/1.1 302 Found\r\nLocation: {target_url}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write redirect response"); + let second_request = tokio::time::timeout(Duration::from_millis(250), source.accept()) + .await + .is_ok(); + (request, second_request) + }); + + deliver_one( + &routes, + &relay_keypair, + &store, + &transport, + delivery.clone(), + ) + .await; + + let (request, second_source_request) = + tokio::time::timeout(Duration::from_secs(2), source_task) + .await + .expect("source server completed") + .expect("source server task"); + assert!(request.0.starts_with("POST /mentions HTTP/1.1")); + assert!(!request.1.is_empty()); + assert!( + !second_source_request, + "source received more than one request" + ); + let target_request = tokio::time::timeout(Duration::from_secs(2), target_task) + .await + .expect("target server completed") + .expect("target server task"); + assert_eq!( + store.calls(), + [StoreCall::Fail(delivery.id, delivery.claim_id)] + ); + assert!( + target_request.is_none(), + "redirect target unexpectedly received a request" + ); + } + + #[tokio::test] + async fn exhausted_delivery_attempts_fail_permanently() { + let (routes, relay_keypair, delivery) = + delivery_fixture(buzz_db::operator_listener::MAX_DELIVERY_ATTEMPTS); + let store = MockStore::default(); + let transport = MockTransport::new(Err("connection reset".to_owned())); + + deliver_one( + &routes, + &relay_keypair, + &store, + &transport, + delivery.clone(), + ) + .await; + + assert!(transport.take_request().is_some()); + assert_eq!( + store.calls(), + [StoreCall::Fail(delivery.id, delivery.claim_id)] + ); + } +} diff --git a/crates/buzz-relay/src/push_runtime.rs b/crates/buzz-relay/src/push_runtime.rs index 1e9469f4a4..d47dcc9d61 100644 --- a/crates/buzz-relay/src/push_runtime.rs +++ b/crates/buzz-relay/src/push_runtime.rs @@ -5,15 +5,13 @@ use std::{ time::{Duration, Instant}, }; -use base64::Engine as _; use buzz_core::filter::{filters_match, reader_authorized_for_event}; use chrono::{TimeDelta, Utc}; -use nostr::{EventBuilder, Filter, Kind, Tag}; +use nostr::Filter; use serde::{Deserialize, Serialize}; -use sha2::{Digest as _, Sha256}; use tracing::{error, warn}; -use crate::{handlers::push_lease::Subscription, state::AppState}; +use crate::{handlers::push_lease::Subscription, nip98::nip98_header, state::AppState}; const CLAIM_SECS: i64 = 30; const EVENT_USEFUL_SECS: i64 = 3600; @@ -666,22 +664,6 @@ async fn retry_or_fail( } } -fn nip98_header(keys: &nostr::Keys, url: &str, body: &[u8]) -> anyhow::Result { - let hash = hex::encode(Sha256::digest(body)); - let event = EventBuilder::new(Kind::HttpAuth, "") - .tags([ - Tag::parse(["u", url])?, - Tag::parse(["method", "POST"])?, - Tag::parse(["payload", &hash])?, - Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()])?, - ]) - .sign_with_keys(keys)?; - Ok(format!( - "Nostr {}", - base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&event)?) - )) -} - fn class_rank(_: &str) -> u8 { 1 } @@ -696,6 +678,7 @@ mod tests { routing::post, Json, Router, }; + use nostr::{EventBuilder, Kind, Tag}; use serde_json::Value; use std::{future::IntoFuture, sync::Arc}; use tokio::sync::Mutex; diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 50af16d644..303ecff82f 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -317,6 +317,11 @@ pub fn build_router(state: Arc) -> Router { "/operator/communities", get(api::operator::list_owned_communities).post(api::operator::provision_community), ) + .route( + "/operator/listener/pubkeys", + post(api::operator::register_listener_pubkeys) + .delete(api::operator::remove_listener_pubkeys), + ) .route( "/operator/communities/archive", post(api::operator::archive_community), diff --git a/migrations/0050_operator_listener_mentions.sql b/migrations/0050_operator_listener_mentions.sql new file mode 100644 index 0000000000..10f81f50aa --- /dev/null +++ b/migrations/0050_operator_listener_mentions.sql @@ -0,0 +1,57 @@ +-- Deployment-global operator-listener mention delivery. +-- Listener registrations span communities; community_id on the outbox is event +-- provenance, not a tenant boundary. + +-- Migration 0044 restored this function to the pre-0041 exclusion set. Extend +-- it here so migrated databases keep the outbox outside tenant fencing and +-- community-deletion catalog discovery, matching the desired-state schema. +CREATE OR REPLACE FUNCTION community_write_fence_excluded_table(target NAME) RETURNS BOOLEAN +LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE AS $$ + SELECT target::TEXT = ANY (ARRAY[ + 'community_deletion_requests', 'community_deletion_approvals', + 'community_deletion_checkpoints', 'community_serving_write_leases', + 'community_deletion_executor_heartbeats', 'product_feedback', + 'rate_limit_violations', 'operator_listener_outbox' + ]::TEXT[]) +$$; + +CREATE TABLE operator_listener_pubkeys ( + listener_pubkey BYTEA NOT NULL CHECK (length(listener_pubkey) = 32), + target_pubkey BYTEA NOT NULL CHECK (length(target_pubkey) = 32), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (listener_pubkey, target_pubkey) +); +CREATE INDEX operator_listener_pubkeys_target + ON operator_listener_pubkeys (target_pubkey, listener_pubkey); +CREATE INDEX operator_listener_pubkeys_created_at + ON operator_listener_pubkeys (created_at); + +CREATE TABLE operator_listener_outbox ( + id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(), + listener_pubkey BYTEA NOT NULL CHECK (length(listener_pubkey) = 32), + target_pubkey BYTEA NOT NULL CHECK (length(target_pubkey) = 32), + community_id UUID NOT NULL, + event_id BYTEA NOT NULL CHECK (length(event_id) = 32), + event_kind INTEGER NOT NULL, + event_created_at TIMESTAMPTZ NOT NULL, + state TEXT NOT NULL DEFAULT 'pending' + CHECK (state IN ('pending', 'sending')), + attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0), + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), + lease_until TIMESTAMPTZ, + claim_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (listener_pubkey, target_pubkey, community_id, event_id) +); +CREATE INDEX operator_listener_outbox_due + ON operator_listener_outbox (next_attempt_at, created_at, id) + WHERE state = 'pending'; +CREATE INDEX operator_listener_outbox_recovery + ON operator_listener_outbox (lease_until, created_at, id) + WHERE state = 'sending'; +CREATE INDEX operator_listener_outbox_created_at + ON operator_listener_outbox (created_at); + +INSERT INTO _operator_global_tables (table_name, reason) VALUES + ('operator_listener_pubkeys', 'deployment-global target registrations for operator listeners'), + ('operator_listener_outbox', 'deployment-global mention delivery queue; community_id is event provenance'); diff --git a/schema/schema.sql b/schema/schema.sql index 1a0a849f7a..1ba4bb0399 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -1494,7 +1494,7 @@ LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE AS $$ 'community_deletion_requests', 'community_deletion_approvals', 'community_deletion_checkpoints', 'community_serving_write_leases', 'community_deletion_executor_heartbeats', 'product_feedback', - 'rate_limit_violations' + 'rate_limit_violations', 'operator_listener_outbox' ]::TEXT[]) $$; @@ -1878,6 +1878,51 @@ CREATE INDEX idx_relay_admin_outbox_pending INSERT INTO _operator_global_tables (table_name, reason) VALUES ('relay_admin_outbox', 'deployment-global enforcement artifact delivery queue'); +-- ── Operator-listener mention delivery ────────────────────────────────────── +-- Listener registrations are deployment-global. The outbox records community +-- provenance for the event, but is intentionally not tenant-owned. + +CREATE TABLE operator_listener_pubkeys ( + listener_pubkey BYTEA NOT NULL CHECK (length(listener_pubkey) = 32), + target_pubkey BYTEA NOT NULL CHECK (length(target_pubkey) = 32), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (listener_pubkey, target_pubkey) +); +CREATE INDEX operator_listener_pubkeys_target + ON operator_listener_pubkeys (target_pubkey, listener_pubkey); +CREATE INDEX operator_listener_pubkeys_created_at + ON operator_listener_pubkeys (created_at); + +CREATE TABLE operator_listener_outbox ( + id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(), + listener_pubkey BYTEA NOT NULL CHECK (length(listener_pubkey) = 32), + target_pubkey BYTEA NOT NULL CHECK (length(target_pubkey) = 32), + community_id UUID NOT NULL, + event_id BYTEA NOT NULL CHECK (length(event_id) = 32), + event_kind INTEGER NOT NULL, + event_created_at TIMESTAMPTZ NOT NULL, + state TEXT NOT NULL DEFAULT 'pending' + CHECK (state IN ('pending', 'sending')), + attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0), + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), + lease_until TIMESTAMPTZ, + claim_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (listener_pubkey, target_pubkey, community_id, event_id) +); +CREATE INDEX operator_listener_outbox_due + ON operator_listener_outbox (next_attempt_at, created_at, id) + WHERE state = 'pending'; +CREATE INDEX operator_listener_outbox_recovery + ON operator_listener_outbox (lease_until, created_at, id) + WHERE state = 'sending'; +CREATE INDEX operator_listener_outbox_created_at + ON operator_listener_outbox (created_at); + +INSERT INTO _operator_global_tables (table_name, reason) VALUES + ('operator_listener_pubkeys', 'deployment-global target registrations for operator listeners'), + ('operator_listener_outbox', 'deployment-global mention delivery queue; community_id is event provenance'); + -- ── Relay operator audit (append-only roster mutation trail) ───────────────── -- One row per PUT/DELETE /operators/{pubkey} mutation. The roster is the -- deployment-wide root of trust and its mutations overwrite/remove in place; From 1846094b3111a059f856bb4f3466c102fad17ef7 Mon Sep 17 00:00:00 2001 From: Jarrod Sibbison <72240382+jsibbison-square@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:17:40 +1000 Subject: [PATCH 02/12] fix(db): limit event transactions to listener mention kinds (#7932) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Keep non-listener-mention event writes on the plain insert path without opening a transaction. - Preserve the transaction around listener mention event insertion and outbox enqueue. ## Validation - `cargo fmt --all -- --check` (Hermit) - Tests not run. Follow-up to Baxen’s review of #7793, point 1. Signed-off-by: Implementor <691cca7a870db1dad6990d5938d1b4a2a2ca9647ca81290b536c17b06b2e473f@buzz.block.builderlab.xyz> Co-authored-by: Implementor <691cca7a870db1dad6990d5938d1b4a2a2ca9647ca81290b536c17b06b2e473f@buzz.block.builderlab.xyz> --- crates/buzz-db/src/store/event.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index 537f338030..6259a02c5d 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -355,15 +355,19 @@ pub async fn insert_event( event: &Event, channel_id: Option, ) -> Result<(StoredEvent, bool)> { - let connection = crate::observability::acquire_writer( + let mut connection = crate::observability::acquire_writer( pool, crate::observability::WriterOperation::EventWrite, ) .await?; - let mut tx = Transaction::begin(connection, None).await?; - let result = insert_event_in_transaction(&mut tx, community_id, event, channel_id).await?; - tx.commit().await?; - Ok(result) + if crate::operator_listener::is_listener_mention_kind(u32::from(event.kind.as_u16())) { + let mut tx = Transaction::begin(connection, None).await?; + let result = insert_event_in_transaction(&mut tx, community_id, event, channel_id).await?; + tx.commit().await?; + Ok(result) + } else { + insert_event_on(&mut connection, community_id, event, channel_id).await + } } /// Insert a Nostr event in a caller-owned PostgreSQL transaction. From ebe99a46e8802b9ff20fdf6a1028ce93bdefaa43 Mon Sep 17 00:00:00 2001 From: Jarrod Sibbison <72240382+jsibbison-square@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:18:15 +1000 Subject: [PATCH 03/12] fix(relay): fail startup on invalid operator listener config (#7933) Addresses point 3 of [previous comment](https://github.com/block/buzz/pull/7793#pullrequestreview-5333444423) "3. Fail fast on a bad BUZZ_OPERATOR_LISTENERS (config.rs:857)" ## Summary - Propagate malformed `BUZZ_OPERATOR_LISTENERS` values from `Config::from_env` as `ConfigError`, including invalid UTF-8. - Update startup config tests to assert returned errors and restore the original admin warning capture helper, removing the shared helper introduced only for listener log assertions. - Cover valid and absent listener values through `Config::from_env`. ## Context Follow-up to the review of #7793 specifically [reverting some config logging parts](https://github.com/block/buzz/pull/7793/changes#diff-eaa0eeb209ff1ad6212ffa56d3a983bb732858647c9fb227ef7483a3363b5a71). Invalid listener configuration currently logs an error and silently disables listener delivery. ## Verification - `cargo fmt --all -- --check` passed with Hermit. Signed-off-by: Implementor <691cca7a870db1dad6990d5938d1b4a2a2ca9647ca81290b536c17b06b2e473f@buzz.block.builderlab.xyz> Co-authored-by: Implementor <691cca7a870db1dad6990d5938d1b4a2a2ca9647ca81290b536c17b06b2e473f@buzz.block.builderlab.xyz> --- crates/buzz-relay/src/config.rs | 134 +++++++++++++++++--------------- 1 file changed, 70 insertions(+), 64 deletions(-) diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 899b5422f2..da69b7cbc2 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -855,23 +855,12 @@ impl Config { } let operator_listener_delivery_urls = match std::env::var("BUZZ_OPERATOR_LISTENERS") { - Ok(raw) => match parse_operator_listener_delivery_urls(&raw) { - Ok(urls) => urls, - Err(parse_error) => { - error!( - error = %parse_error, - "invalid BUZZ_OPERATOR_LISTENERS; operator-listener mention delivery is disabled" - ); - HashMap::new() - } - }, + Ok(raw) => parse_operator_listener_delivery_urls(&raw)?, Err(std::env::VarError::NotPresent) => HashMap::new(), Err(error) => { - error!( - error = %error, - "BUZZ_OPERATOR_LISTENERS must be valid UTF-8; operator-listener mention delivery is disabled" - ); - HashMap::new() + return Err(ConfigError::InvalidValue(format!( + "BUZZ_OPERATOR_LISTENERS must be valid UTF-8: {error}" + ))); } }; if !operator_listener_delivery_urls.is_empty() && relay_operator_api_origin.is_none() { @@ -1542,8 +1531,14 @@ mod tests { config } - /// Capture the tracing output emitted during `Config::from_env()`. - fn capture_config_logs() -> (Result, String) { + /// Like `config_with_admin_env`, but also captures the tracing output + /// emitted during `Config::from_env()` so a test can assert the startup + /// warning fired. The `BUZZ_ADMIN_TOKEN` warning is the sole behavioral + /// value of retaining the guards (the variable is otherwise inert), so it + /// must be regression-protected: deleting a warn block has to fail a test. + fn config_with_admin_env_capturing_logs( + values: &[(&str, Option<&str>)], + ) -> (Result, String) { use std::sync::{Arc, Mutex}; #[derive(Clone)] @@ -1578,49 +1573,19 @@ mod tests { }) .with_ansi(false) .finish(); - let config = tracing::subscriber::with_default(subscriber, Config::from_env); + let config = + tracing::subscriber::with_default(subscriber, || config_with_admin_env(values)); let captured = String::from_utf8(buf.lock().unwrap().clone()).unwrap_or_default(); (config, captured) } - /// Like `config_with_admin_env`, but also captures the tracing output - /// emitted during `Config::from_env()` so a test can assert the startup - /// warning fired. The `BUZZ_ADMIN_TOKEN` warning is the sole behavioral - /// value of retaining the guards (the variable is otherwise inert), so it - /// must be regression-protected: deleting a warn block has to fail a test. - fn config_with_admin_env_capturing_logs( - values: &[(&str, Option<&str>)], - ) -> (Result, String) { - const KEYS: [&str; 3] = ["BUZZ_ADMIN_HOST", "BUZZ_ADMIN_TOKEN", "BUZZ_ADMIN_AUTH"]; - let previous: Vec<_> = KEYS - .iter() - .map(|key| (*key, std::env::var_os(key))) - .collect(); - for key in KEYS { - std::env::remove_var(key); - } - for (key, value) in values { - match value { - Some(value) => std::env::set_var(key, value), - None => std::env::remove_var(key), - } - } - let result = capture_config_logs(); - for (key, value) in previous { - match value { - Some(value) => std::env::set_var(key, value), - None => std::env::remove_var(key), - } - } - result - } - - fn config_with_operator_listeners_capturing_logs( - raw: &str, - ) -> (Result, String) { + fn config_with_operator_listeners(raw: Option<&str>) -> Result { let previous = std::env::var_os("BUZZ_OPERATOR_LISTENERS"); - std::env::set_var("BUZZ_OPERATOR_LISTENERS", raw); - let result = capture_config_logs(); + match raw { + Some(value) => std::env::set_var("BUZZ_OPERATOR_LISTENERS", value), + None => std::env::remove_var("BUZZ_OPERATOR_LISTENERS"), + } + let result = Config::from_env(); match previous { Some(value) => std::env::set_var("BUZZ_OPERATOR_LISTENERS", value), None => std::env::remove_var("BUZZ_OPERATOR_LISTENERS"), @@ -2475,18 +2440,59 @@ mod tests { } #[test] - fn malformed_operator_listener_config_is_logged_and_disables_delivery() { + fn malformed_operator_listener_config_prevents_startup() { let _guard = ENV_MUTEX.lock().unwrap(); - let (result, logs) = config_with_operator_listeners_capturing_logs(";"); - let config = result.expect("malformed listener config must not stop relay startup"); + let result = config_with_operator_listeners(Some(";")); - assert!(config.operator_listener_delivery_urls.is_empty()); - assert!(logs.contains("ERROR"), "expected an ERROR line: {logs:?}"); - assert!(logs.contains("BUZZ_OPERATOR_LISTENERS"), "logs: {logs:?}"); - assert!( - logs.contains("operator-listener mention delivery is disabled"), - "logs: {logs:?}" + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref message)) + if message.contains("BUZZ_OPERATOR_LISTENERS") + )); + } + + #[cfg(unix)] + #[test] + fn non_utf8_operator_listener_config_prevents_startup() { + use std::os::unix::ffi::OsStringExt; + + let _guard = ENV_MUTEX.lock().unwrap(); + let previous = std::env::var_os("BUZZ_OPERATOR_LISTENERS"); + std::env::set_var( + "BUZZ_OPERATOR_LISTENERS", + std::ffi::OsString::from_vec(vec![0xff]), ); + + let result = Config::from_env(); + + match previous { + Some(value) => std::env::set_var("BUZZ_OPERATOR_LISTENERS", value), + None => std::env::remove_var("BUZZ_OPERATOR_LISTENERS"), + } + + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref message)) + if message.contains("BUZZ_OPERATOR_LISTENERS must be valid UTF-8") + )); + } + + #[test] + fn valid_and_absent_operator_listener_config_preserve_routes() { + let _guard = ENV_MUTEX.lock().unwrap(); + let key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let raw = format!("{key}:https://listener.example/mentions"); + let config = config_with_operator_listeners(Some(&raw)).expect("valid listener config"); + assert_eq!( + config + .operator_listener_delivery_urls + .get(key) + .map(url::Url::as_str), + Some("https://listener.example/mentions") + ); + + let config = config_with_operator_listeners(None).expect("absent listener config"); + assert!(config.operator_listener_delivery_urls.is_empty()); } #[test] From 447951a19c00edfa6432636f50782445929ab678 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 28 Sep 2026 12:56:52 -0400 Subject: [PATCH 04/12] =?UTF-8?q?=F0=9F=A4=96=20docs(nip-fi):=20remove=20i?= =?UTF-8?q?mplementation=20references=20from=20the=20spec=20(#7912)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This removes implementation references and implementation status from `docs/nips/NIP-FI.md` so the spec holds normative text only. It drops the Blossom compliance note, the known-gap notes, crate/file paths and PR numbers, roadmap "supersedes" sentences, doc-history lines, and the design-rationale note on session-only vs deny-until-TTL (its restart re-push SHOULD remains stated normatively elsewhere). Two wording changes carry normative weight: `PUT /media/upload` is listed as a plain kind-24242 upload route instead of a temporary alias, and the JWKS fetcher's SSRF controls become a MUST instead of a description. It doesn't depend on any enforcement PR and can merge at any time. Related: #7265 --------- Signed-off-by: Will Pfleger Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> --- docs/nips/NIP-FI.md | 76 +++++++-------------------------------------- 1 file changed, 12 insertions(+), 64 deletions(-) diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index fee7909dfd..9448d6bc66 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -190,7 +190,7 @@ community configuration is missing or unavailable. ### Multi-issuer registry -The global [`IssuerRegistry`](../../crates/buzz-auth/src/nip_fi/config.rs) remains +The global `IssuerRegistry` remains shared for issuer policy and JWKS lookup; community authorization is a separate deployment mapping from canonical host URI to `(expected_aud, authorized_issuers)`. `VerifyAssertion` consumes the @@ -198,7 +198,7 @@ Host-resolved mapping, so a globally known issuer is not implicitly trusted by every community. The `nostr_pubkey` claim is unconditionally required — absence rejects -regardless of issuer policy (NIP-FI v2, PR #7221). +regardless of issuer policy. ### JWKS snapshot @@ -396,40 +396,6 @@ specified below is the recovery path for lost propagation, exactly as for relay restart. A success response from the receiving process does not imply cluster-wide application. -> **Note — adopted position (session-only vs deny-until-TTL):** -> -> The session-only model closes existing connections but places no -> protocol-level bound on reconnection. For session-only, if the issuer also -> stops issuing new assertions after a disconnect call, cumulative residual -> access is bounded by: -> -> ``` -> max(0, min(exp, iat + maximum_assertion_age) - now) -> ``` -> -> `max_connection_lifetime_seconds` only partitions that interval into -> individual sessions; it does not shorten the total window. If the issuer -> continues issuing new assertions, cumulative access extends indefinitely. -> -> The **deny-until-TTL** model closes this reconnect window. The relay holds a -> memory-resident deny set keyed by `(iss, pubkey)`, with absolute expiry -> carried by the issuer as `until` in the disconnect command. Any admission -> attempt for that key is denied until the entry expires. The issuer must boot -> the deny TTL to outlast the longest live assertion it may have already -> issued; otherwise an unexpired assertion lets the client back in the moment -> the entry expires. -> -> The deny set is RAM-cache, not a database — the same operational posture as -> the JWKS snapshot. A relay restart clears the set; the issuer, as the -> durable system of record for revocations, SHOULD re-push still-active denies -> when it observes a relay restart (same publish/cache pattern as JWKS). A -> fresh relay MAY consult the issuer before first admissions to close the -> startup race; this is non-normative. -> -> This design was chosen because session-only disconnection must outlast -> the live socket to mean anything as a revocation primitive; a self-expiring -> RAM entry preserves the zero-persistence guarantee while closing the window. - ### Transport The disconnect endpoint is an authenticated issuer→relay API, not a public @@ -515,7 +481,7 @@ VerifyCommandJwt(token, request_method, request_path, request_body_pubkey): // leaves neither behind: the jti is not burned, and the caller may safely // retry the same signed command. Performing the jti reservation alone // (without the deny-entry insertion) would burn the command identity on a - // capacity failure, making the new 503 contract unimplementable. Capacity + // capacity failure, making the 503 contract unimplementable. Capacity // is checked against the per-issuer bound for claims.iss; a different // issuer's capacity exhaustion does not produce a 503 here. // On same-key collision: retain max(existing_until, until) — never shorten @@ -535,9 +501,6 @@ VerifyCommandJwt(token, request_method, request_path, request_body_pubkey): Any failure at any step is fail-closed: no side effects occur and the relay returns the appropriate error. -This verifier and the disconnect API endpoint are follow-on code changes -outside this PR. - ### Request ```text @@ -620,10 +583,9 @@ to all three requirements on all three endpoints, including both POST endpoints; it is not a payload-only exemption. This exemption is required by Git's credential protocol. The credential helper -receives only credential metadata and never sees request bodies -(`crates/git-credential-nostr/src/lib.rs:98-132`), so a body hash cannot exist -in the signed event. This limitation is architectural to Git's credential -protocol. +receives only credential metadata and never sees request bodies, so a body +hash cannot exist in the signed event. This limitation is architectural to +Git's credential protocol. The NIP-FI assertion and pairing requirement is unchanged and applies per request on every one of these endpoints. Each request MUST still carry and @@ -645,8 +607,7 @@ The following compensating controls are REQUIRED for this proof pattern: 7. Every push MUST pass pre-receive hook push authorization. This exemption applies solely to the Git credential-helper proof pattern on -these three endpoints. It is not a precedent for any other surface. A client -change that enables per-request signing supersedes this exemption. +these three endpoints. It is not a precedent for any other surface. ### Media possession-proof exception (Blossom, kind 24242) @@ -656,8 +617,7 @@ alternative possession format — not a general "signed Nostr event" escape hatc and not precedent for any other surface. Any future alternative proof format requires an explicit axis-by-axis security review covering: payload/resource binding, method/operation scope, audience/tenant scope, freshness, signature/key -pairing, transport cardinality, and cross-endpoint replay. Per-request NIP-98 -support supersedes this exception when available. +pairing, transport cardinality, and cross-endpoint replay. #### Scope fence @@ -665,7 +625,7 @@ Kind-24242 proofs are valid **only** on the following routes and operations: | Proof type (`t` tag) | Valid route | Method | |---|---|---| -| `upload` | `PUT /upload` (and temporary alias `PUT /media/upload` until that alias is removed) | PUT | +| `upload` | `PUT /upload`, `PUT /media/upload` | PUT | | `get` | `GET /media/{hash…}`, `HEAD /media/{hash…}` | GET, HEAD | No other protected route may accept a kind-24242 proof. A kind-24242 event @@ -738,17 +698,6 @@ requirement: full assertion verification, exact key equality between the assertion's `nostr_pubkey` claim and the kind-24242 event's public key, and deny-map enforcement (see Admission procedure, steps 1–6). -The effectiveness of deny-map enforcement is contingent on the real -issuer-scoped deny map. Until that map is operational, the stub implementation -constitutes a **known gap** in this section's security guarantees. - -#### Compliance note - -The implementation as of PR #7264 pairs via a permissive Blossom verifier and -is explicitly non-compliant with this section. The named gaps are: -multi-tag acceptance, a 3600-second proof window, and an optional `server` -tag. These are resolved when the bounded hardening task lands. - ### Request format Each protected HTTP request MUST present both of the following: @@ -868,7 +817,6 @@ normative behavior for them: - Directory integration and account-offboarding automation: issuer-side. - Audit logging beyond what the relay operator chooses to retain: issuer-side. - Delegation: out of scope. -- Companion profiles (NIP-FI-EDGE, NIP-FI-LIFECYCLE, NIP-FI-DELEG, NIP-FI-CONF, NIP-FI-MODEL): removed. ## Discovery @@ -979,10 +927,10 @@ gap by requiring assertion verification on every protected HTTP request. The per-request re-verification model means there is no cached admission window; a deny-set entry takes effect on the very next request. -**SSRF.** The JWKS fetcher implements SSRF protection: HTTPS-only URI +**SSRF.** The JWKS fetcher MUST enforce SSRF protection: HTTPS-only URI validation, DNS resolution with IP deny-list enforcement, address pinning to -prevent DNS rebinding TOCTOU, and redirect denial. The complete IANA -Special-Purpose address deny table is implemented; see `crates/buzz-core/src/network.rs`. +prevent DNS rebinding TOCTOU, and redirect denial. The IP deny-list covers +the complete IANA Special-Purpose address registry. **Issuer compromise.** A compromised assertion issuer can impersonate any identity but cannot prove possession of the assertion-named Nostr key. The NIP-42 From b37e477211313a94344a31aea4b180ed4effe0b8 Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Mon, 28 Sep 2026 14:02:12 -0400 Subject: [PATCH 05/12] Make relay readiness process-local (#7341) ## Summary Make Kubernetes readiness depend only on the relay process lifecycle, so a shared Postgres or Redis slowdown cannot withdraw every pod at once. Move dependency checks to one fixed-cadence loop per pod. The loop permits only one evaluation at a time, emits metrics after each sample, and caches a timestamped report. `/_status` reads that cache without calling Postgres, Redis, or the deletion catalog. It reports `not_yet_sampled`, `fresh`, or `stale`. Each completed sample also writes a Unix timestamp gauge, so Datadog can compute sample age at query time. Fail closed when the community lifecycle lookup errors. An inconclusive tenant-state lookup no longer admits a socket to AUTH or REQ handling. ### Related issue None found. This addresses the elevated relay HTTP 500 and reconnect incident investigated on 2026-09-03. ### Tradeoff The readiness gauge now reports the latest private-probe observation, not the shutdown transition itself. After shutdown starts, a scrape can still see `1` until the next readiness probe refreshes the gauge to `0`. Kubernetes routing is unaffected because it uses the readiness response from the authoritative process state. A terminal `0` was never guaranteed: even a transition-owned gauge could update in process and then exit before Prometheus scraped it. If no scrape occurs before exit, both designs miss the terminal sample. The new design adds at most the interval until the next readiness probe when a scrape occurs during shutdown. Dependency status is now sampled every 30 seconds rather than on demand. A slow evaluation delays the next sample instead of stacking more work. The status payload exposes the cached report age. The completion timestamp stays unchanged when sampling stops, so `time() - timestamp` crosses the stale threshold without a server aging loop. Outcome counters and duration histograms remain cumulative and cannot provide no-data detection. ### Testing Gimli exercised exact SHA `edff1ec4a5362e132e367bf7449d2b0a68f1e9a9` on an isolated Blox stack. Repeated `/_status` bursts caused no dependency calls, metrics advanced without status traffic, stale status remained visible, and lifecycle lookup failures admitted no AUTH or REQ handling. Follow-up verification at `fdbb394ed598c94cde85ed01cb11e9de0ee8f00d` confirmed sampler-owned outcome and duration emission without status traffic and bound the real startup seam. The final timestamp-gauge change is covered by targeted red/green tests and the same boot regression at `c0f36e22b527c2fd6a63a548d036d006ffa17171`. ### Complexity Readiness remains one process-local sample per probe. Dependency I/O now has one owner and one execution path; request volume cannot increase dependency-check load. One completion timestamp replaces the prior server-side age update path; Datadog computes age without another loop. Community admission also returns to one fail-closed rule for inactive and unknown tenant state. Generated with [Claude Code](https://claude.ai/code) --------- Signed-off-by: tornquist Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Codex Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5 Co-authored-by: Amp --- .github/workflows/_ci-relay.yml | 2 + ARCHITECTURE.md | 2 +- Justfile | 2 +- crates/buzz-relay/src/lib.rs | 3 +- crates/buzz-relay/src/main.rs | 25 +- crates/buzz-relay/src/metrics.rs | 41 +- crates/buzz-relay/src/readiness.rs | 1461 +++++++++++++++------ crates/buzz-relay/src/router.rs | 903 ++++++++----- crates/buzz-relay/src/state.rs | 283 +++- crates/buzz-relay/tests/boot_lifecycle.rs | 350 ++++- deploy/charts/buzz/README.md | 123 +- docs/deployment-identity.md | 19 + scripts/run-tests.sh | 15 +- 13 files changed, 2434 insertions(+), 795 deletions(-) diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index b616b8e7f3..cf87d7a6ca 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -198,6 +198,8 @@ jobs: with: name: desktop-e2e-relay path: target/ci + - name: Restore relay executable permission + run: chmod +x ./target/ci/buzz-relay - name: PostgreSQL-backed tests env: BUZZ_POSTGRES_ADMIN_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/postgres diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ae82c131ec..ed34ee4c8d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -644,7 +644,7 @@ pub enum AuthState { Pending { challenge: String }, Authenticated(AuthContext), | GET | `/.well-known/nostr.json` | NIP-05 identity | | GET | `/health` | Health check | | GET | `/_liveness` | Liveness probe | -| GET | `/_readiness` | Readiness probe | +| GET | `/_readiness` | Readiness probe — local process lifecycle only | | POST | `/events` | Submit a signed Nostr event over HTTP (same ingest path as WebSocket `EVENT`) | | POST | `/query` | Query Nostr events over HTTP with NIP-01 filters | | POST | `/count` | Count Nostr events over HTTP with NIP-45 filters | diff --git a/Justfile b/Justfile index 8bc31ab21a..6377b290db 100644 --- a/Justfile +++ b/Justfile @@ -472,7 +472,7 @@ test-unit: # because they live in the binary target; the nested # `tests::postgres_tests::` stays in the PostgreSQL lane. cargo nextest run -p buzz-relay --lib --bin buzz-relay \ - -E 'test(/^api::admin::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/)) - test(/^tests::postgres_tests::/))' + -E 'test(/^api::admin::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/)) - test(/^tests::postgres_tests::/))' # ACP author-gate and queue tests protect the trust boundary between # relay events and agent prompts. They are infra-free; ignored lifecycle # tests remain excluded and run in their dedicated integration lanes. diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 6991ebd368..70f3ef4892 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -45,7 +45,8 @@ pub mod operator_listener; pub mod protocol; /// Durable NIP-PL matcher and delivery worker. pub mod push_runtime; -mod readiness; +/// Readiness-probe telemetry and the per-pod dependency sampler behind `/_status`. +pub mod readiness; /// Axum router construction. pub mod router; /// Shared application state. diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 6adea419da..d2cf5fa787 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -227,6 +227,10 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { let usage_interval_secs = usage_metrics_interval_secs(); let usage_idle_timeout_secs = usage_metrics_idle_timeout_secs(usage_interval_secs); + let dependency_sample_completion_republish_interval = + buzz_relay::readiness::dependency_sample_completion_republish_interval( + usage_idle_timeout_secs, + ); let (boot, ()) = boot.run_required( StartupPhase::MetricsBind, || relay_metrics::try_install(config.metrics_port, usage_idle_timeout_secs), @@ -244,6 +248,7 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { info!( port = config.metrics_port, idle_timeout_secs = usage_idle_timeout_secs, + completion_republish_secs = dependency_sample_completion_republish_interval.as_secs(), "Prometheus metrics exporter started" ); @@ -455,7 +460,13 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { cfg.create_pool(Some(deadpool_redis::Runtime::Tokio1)) .map_err(|e| anyhow::anyhow!("Redis pool creation failed: {e}"))? }; - let redis_health_pool = redis_pool.clone(); // cheap Arc clone — shared with readiness handler + let redis_health_pool = redis_pool.clone(); // cheap Arc clone — shared with AppState + // One-time bootstrap gate, deliberately before AppState and therefore before + // the health listener binds. Post-start Redis failures are dependency + // failures and must never move readiness; never having connected at all is + // a broken deployment, not a blip. + buzz_relay::state::verify_redis_command_path(&redis_health_pool).await?; + info!("Redis command path connected"); let pubsub = Arc::new( PubSubManager::new(&config.redis_url, redis_pool) .await @@ -1100,6 +1111,16 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { )); } + // Per-pod dependency diagnostics runtime: one seam starts the dependency + // sampler and its independent completion-epoch republisher together. + { + let diagnostics_state = Arc::clone(&state); + buzz_relay::readiness::start_dependency_sampler_and_completion_publisher( + diagnostics_state, + dependency_sample_completion_republish_interval, + ); + } + // Cross-pod connection-control consumer: receive disconnect commands from // Redis pub/sub (published by the pod that recorded a ban) and close any // matching local sockets. A member's live connections may land on any pod, @@ -1273,6 +1294,8 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { serve(router, health_router, Arc::clone(&state)).await?; state.community_revalidator_cancel.cancel(); + state.dependency_sampler_cancel.cancel(); + state.dependency_completion_publisher_cancel.cancel(); // Signal the audit worker to stop accepting, flush buffered entries, and // exit. Uses a CancellationToken so it works regardless of how many diff --git a/crates/buzz-relay/src/metrics.rs b/crates/buzz-relay/src/metrics.rs index c1e72f7b75..59bd90a0d7 100644 --- a/crates/buzz-relay/src/metrics.rs +++ b/crates/buzz-relay/src/metrics.rs @@ -290,6 +290,7 @@ pub fn try_install(port: u16, gauge_idle_timeout_secs: u64) -> Result<(), Metric metrics::set_global_recorder(recorder) .map_err(|_error| MetricsInstallError::RecorderConflict)?; describe_readiness_metrics(); + describe_community_admission_metrics(); describe_db_pool_metrics(); describe_auth_metrics(); initialize_auth_metric_series(); @@ -306,24 +307,42 @@ pub fn install(port: u16, gauge_idle_timeout_secs: u64) { .unwrap_or_else(|error| panic!("metrics exporter must install exactly once: {error}")); } -/// Register the frozen readiness metric descriptions with the active recorder. +/// Register the frozen readiness and dependency-diagnostic metric descriptions. +/// +/// The two `buzz_readiness_*` probe families describe local process lifecycle. +/// The dependency families keep their names for dashboard continuity but are +/// published by the per-pod dependency sampler, not by the Kubernetes probe or +/// by an `/_status` request — a shared-dependency failure no longer deroutes +/// the pod, and nobody has to read the endpoint for the metrics to move. pub(crate) fn describe_readiness_metrics() { metrics::describe_counter!( "buzz_readiness_checks_total", - "Kubernetes health-listener readiness probes by terminal bounded reason" + "Kubernetes health-listener readiness probes by lifecycle reason (ready, shutting_down)" ); metrics::describe_counter!( "buzz_readiness_dependency_checks_total", - "Completed readiness dependency attempts by dependency and bounded outcome" + "Completed dependency-sampler attempts by dependency and bounded outcome" ); metrics::describe_histogram!( "buzz_readiness_check_duration_seconds", metrics::Unit::Seconds, - "Completed readiness check duration without outcome label multiplication" + "Completed dependency-sampler check duration without outcome label multiplication" ); metrics::describe_gauge!( "buzz_readiness_state", - "Latest publishable readiness state by check, where 1 is ready and 0 is not ready" + "Latest private readiness-probe observation, where 1 is ready and 0 is shutting down" + ); + metrics::describe_gauge!( + "buzz_readiness_dependency_sample_completed_timestamp_seconds", + "Unix time the cached /_status dependency report completed, absent until the first sample completes; sampler completion advances it and the publisher re-emits it" + ); +} + +/// Register the bounded community-admission contract. +pub(crate) fn describe_community_admission_metrics() { + metrics::describe_counter!( + "buzz_community_admission_checks_total", + "Durable community-active checks at socket admission by bounded outcome" ); } @@ -541,7 +560,17 @@ pub(crate) fn readiness_test_recorder() -> ( metrics_exporter_prometheus::PrometheusRecorder, metrics_exporter_prometheus::PrometheusHandle, ) { - let recorder = configured_prometheus_builder(300).build_recorder(); + readiness_test_recorder_with_idle_timeout(300) +} + +#[cfg(test)] +pub(crate) fn readiness_test_recorder_with_idle_timeout( + gauge_idle_timeout_secs: u64, +) -> ( + metrics_exporter_prometheus::PrometheusRecorder, + metrics_exporter_prometheus::PrometheusHandle, +) { + let recorder = configured_prometheus_builder(gauge_idle_timeout_secs).build_recorder(); let handle = recorder.handle(); (recorder, handle) } diff --git a/crates/buzz-relay/src/readiness.rs b/crates/buzz-relay/src/readiness.rs index 79a1a98570..f39689f20a 100644 --- a/crates/buzz-relay/src/readiness.rs +++ b/crates/buzz-relay/src/readiness.rs @@ -1,43 +1,108 @@ -//! Readiness dependency evaluation and ordered metrics publication. +//! Readiness-probe telemetry and the dependency diagnostics behind `/_status`. //! -//! [`ReadinessCoordinator`] is process-owned. Its mutex is the linearization -//! point shared by health-probe commits and terminal shutdown, so an older -//! evaluation can never overwrite newer gauges or publish ready after shutdown. +//! Readiness is deliberately *not* a dependency question. A shared Postgres or +//! Redis failure is shared by every replica, so evaluating it in the probe took +//! the whole deployment out of the load balancer at once and left a reconnect +//! burst with nowhere to land. The Kubernetes probe therefore answers from this +//! process's own lifecycle (see [`crate::router`]), and the same dependency +//! evaluation is reported on the diagnostic `/_status` endpoint, which is never +//! wired to a probe. +//! +//! Dependency evaluation is also decoupled from requests. One per-pod loop +//! ([`run_dependency_sampler`]) evaluates on a fixed cadence, publishes the +//! dependency metrics, and caches the report; `/_status` only reads that cache. +//! Evaluating per request made the load a pressured dependency sees depend on +//! how often someone looked at the endpoint, with nothing bounding how many +//! evaluations could be in flight at once. use std::future::Future; -use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; -use std::time::Duration; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, SystemTime}; use buzz_db::{Db, DbError, DbReadinessOutcome}; use tokio::time::Instant; +use tokio_util::sync::CancellationToken; -const READINESS_TIMEOUT: Duration = Duration::from_secs(2); +use crate::state::AppState; + +const DEPENDENCY_TIMEOUT: Duration = Duration::from_secs(2); + +/// Fixed cadence of the per-pod dependency sampling loop. +/// +/// Slow enough that a pod adds negligible load to a shared dependency, fast +/// enough that an operator opening `/_status` during an incident reads +/// something current. Documented in `deploy/charts/buzz/README.md`. +pub const DEPENDENCY_SAMPLE_INTERVAL: Duration = Duration::from_secs(30); + +/// The completion-epoch publisher always refreshes at least this frequently. +/// +/// Main computes a cadence from the configured gauge idle timeout and caps it +/// at this value so the completion timestamp series survives idle eviction +/// without adding high-frequency noise. +const DEPENDENCY_SAMPLE_COMPLETION_REPUBLISH_MAX_INTERVAL: Duration = DEPENDENCY_SAMPLE_INTERVAL; + +/// Age past which a cached report is reported stale rather than current. +/// +/// Two cadences: one full cycle can be missed by an evaluation that consumed +/// its whole [`DEPENDENCY_TIMEOUT`] budget, so anything older than that means +/// the sampler itself is not keeping up. +const DEPENDENCY_SAMPLE_STALE_AFTER: Duration = DEPENDENCY_SAMPLE_INTERVAL.saturating_mul(2); /// Closed label set exported by `buzz_readiness_checks_total{reason}`. -#[cfg(test)] -pub(crate) const READINESS_REASON_LABELS: [&str; 12] = [ - "ready", - "shutting_down", - "postgres_pool_timeout", - "postgres_pool_error", - "postgres_query_timeout", - "postgres_query_error", - "redis_pool_timeout", - "redis_pool_error", - "deletion_catalog_timeout", - "deletion_catalog_error", - "overall_timeout", - "multiple_dependencies_failed", -]; - -/// Maximum raw Prometheus series emitted by readiness for one pod. /// -/// - 12 overall reasons +/// Readiness answers a local lifecycle question, so this set cannot grow with +/// the number of shared dependencies the relay talks to. +#[cfg(test)] +pub(crate) const READINESS_REASON_LABELS: [&str; 2] = ["ready", "shutting_down"]; + +/// Maximum raw Prometheus series emitted by readiness and its dependency +/// diagnostics for one pod. +/// +/// - 2 probe reasons /// - 11 valid dependency/outcome pairs (Postgres 5, Redis 3, catalog 3) /// - 4 histograms x (15 configured buckets + `+Inf` + count + sum) = 72 -/// - 4 current-state gauges +/// - 2 readiness gauges (overall lifecycle + completion epoch) #[cfg(test)] -pub(crate) const READINESS_RAW_SERIES_PER_POD: usize = 12 + 11 + (4 * 18) + 4; +pub(crate) const READINESS_RAW_SERIES_PER_POD: usize = 2 + 11 + (4 * 18) + 1 + 1; + +/// Terminal outcome of one readiness probe. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReadinessReason { + Ready, + ShuttingDown, +} + +impl ReadinessReason { + pub(crate) fn label(self) -> &'static str { + match self { + Self::Ready => "ready", + Self::ShuttingDown => "shutting_down", + } + } + + pub(crate) fn is_ready(self) -> bool { + self == Self::Ready + } +} + +/// Records one readiness probe served by the private health listener. +/// +/// The counter and gauge describe the same immutable lifecycle observation. +/// The gauge is therefore the latest private readiness-probe observation, not +/// a transition-owned lifecycle mirror. +pub(crate) fn record_readiness_probe(reason: ReadinessReason) { + metrics::counter!( + "buzz_readiness_checks_total", + "reason" => reason.label(), + ) + .increment(1); + metrics::gauge!("buzz_readiness_state", "check" => "overall").set(if reason.is_ready() { + 1.0 + } else { + 0.0 + }); +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum PostgresOutcome { @@ -130,10 +195,14 @@ impl DeletionCatalogOutcome { } } +/// Aggregate dependency verdict reported in the `/_status` diagnostics body. +/// +/// This is a diagnostic field, never a metric label: it exists so an operator +/// reading `/_status` gets the same one-line summary the readiness body used to +/// carry. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ReadinessReason { +pub(crate) enum DependencyReason { Ready, - ShuttingDown, PostgresPoolTimeout, PostgresPoolError, PostgresQueryTimeout, @@ -146,11 +215,10 @@ pub(crate) enum ReadinessReason { MultipleDependenciesFailed, } -impl ReadinessReason { +impl DependencyReason { pub(crate) fn label(self) -> &'static str { match self { Self::Ready => "ready", - Self::ShuttingDown => "shutting_down", Self::PostgresPoolTimeout => "postgres_pool_timeout", Self::PostgresPoolError => "postgres_pool_error", Self::PostgresQueryTimeout => "postgres_query_timeout", @@ -178,26 +246,18 @@ impl TimedOutcome { } } +/// One completed dependency evaluation. Every dependency always runs, so the +/// report carries three outcomes and never a partial shape. #[derive(Debug, Clone, Copy)] -pub(crate) struct ReadinessEvaluation { - postgres: Option>, - redis: Option>, - deletion_catalog: Option>, - pub(crate) reason: ReadinessReason, +pub(crate) struct DependencyReport { + postgres: TimedOutcome, + redis: TimedOutcome, + deletion_catalog: TimedOutcome, + pub(crate) reason: DependencyReason, total_duration: Duration, } -impl ReadinessEvaluation { - pub(crate) fn shutting_down() -> Self { - Self { - postgres: None, - redis: None, - deletion_catalog: None, - reason: ReadinessReason::ShuttingDown, - total_duration: Duration::ZERO, - } - } - +impl DependencyReport { #[cfg(test)] pub(crate) fn from_results( postgres: TimedOutcome, @@ -216,34 +276,24 @@ impl ReadinessEvaluation { ) -> Self { let reason = final_reason(postgres.outcome, redis.outcome, deletion_catalog.outcome); Self { - postgres: Some(postgres), - redis: Some(redis), - deletion_catalog: Some(deletion_catalog), + postgres, + redis, + deletion_catalog, reason, total_duration, } } - pub(crate) fn is_ready(self) -> bool { - self.reason == ReadinessReason::Ready - } - pub(crate) fn postgres_ready(self) -> bool { - self.postgres - .is_some_and(|result| result.outcome.is_success()) + self.postgres.outcome.is_success() } pub(crate) fn redis_ready(self) -> bool { - self.redis.is_some_and(|result| result.outcome.is_success()) + self.redis.outcome.is_success() } pub(crate) fn deletion_catalog_ready(self) -> bool { - self.deletion_catalog - .is_some_and(|result| result.outcome.is_success()) - } - - fn dependencies_ran(self) -> bool { - self.postgres.is_some() || self.redis.is_some() || self.deletion_catalog.is_some() + self.deletion_catalog.outcome.is_success() } } @@ -251,37 +301,39 @@ fn final_reason( postgres: PostgresOutcome, redis: RedisOutcome, deletion_catalog: DeletionCatalogOutcome, -) -> ReadinessReason { +) -> DependencyReason { let failure_count = usize::from(!postgres.is_success()) + usize::from(!redis.is_success()) + usize::from(!deletion_catalog.is_success()); if failure_count == 0 { - return ReadinessReason::Ready; + return DependencyReason::Ready; } if failure_count > 1 { let all_failures_are_timeouts = (postgres.is_success() || postgres.is_timeout()) && (redis.is_success() || redis.is_timeout()) && (deletion_catalog.is_success() || deletion_catalog.is_timeout()); return if all_failures_are_timeouts { - ReadinessReason::OverallTimeout + DependencyReason::OverallTimeout } else { - ReadinessReason::MultipleDependenciesFailed + DependencyReason::MultipleDependenciesFailed }; } match postgres { - PostgresOutcome::PoolTimeout => ReadinessReason::PostgresPoolTimeout, - PostgresOutcome::PoolError => ReadinessReason::PostgresPoolError, - PostgresOutcome::QueryTimeout => ReadinessReason::PostgresQueryTimeout, - PostgresOutcome::QueryError => ReadinessReason::PostgresQueryError, + PostgresOutcome::PoolTimeout => DependencyReason::PostgresPoolTimeout, + PostgresOutcome::PoolError => DependencyReason::PostgresPoolError, + PostgresOutcome::QueryTimeout => DependencyReason::PostgresQueryTimeout, + PostgresOutcome::QueryError => DependencyReason::PostgresQueryError, PostgresOutcome::Success => match redis { - RedisOutcome::PoolTimeout => ReadinessReason::RedisPoolTimeout, - RedisOutcome::PoolError => ReadinessReason::RedisPoolError, + RedisOutcome::PoolTimeout => DependencyReason::RedisPoolTimeout, + RedisOutcome::PoolError => DependencyReason::RedisPoolError, RedisOutcome::Success => match deletion_catalog { - DeletionCatalogOutcome::OperationTimeout => ReadinessReason::DeletionCatalogTimeout, - DeletionCatalogOutcome::OperationError => ReadinessReason::DeletionCatalogError, - DeletionCatalogOutcome::Success => ReadinessReason::Ready, + DeletionCatalogOutcome::OperationTimeout => { + DependencyReason::DeletionCatalogTimeout + } + DeletionCatalogOutcome::OperationError => DependencyReason::DeletionCatalogError, + DeletionCatalogOutcome::Success => DependencyReason::Ready, }, }, } @@ -303,7 +355,7 @@ async fn evaluate_dependencies( postgres: P, redis: R, deletion_catalog: D, -) -> ReadinessEvaluation +) -> DependencyReport where P: Future, R: Future, @@ -312,7 +364,7 @@ where let started_at = Instant::now(); let (postgres, redis, deletion_catalog) = tokio::join!(timed(postgres), timed(redis), timed(deletion_catalog),); - ReadinessEvaluation::for_dependencies(postgres, redis, deletion_catalog, started_at.elapsed()) + DependencyReport::for_dependencies(postgres, redis, deletion_catalog, started_at.elapsed()) } async fn redis_check(pool: &deadpool_redis::Pool, deadline: Instant) -> RedisOutcome { @@ -345,16 +397,16 @@ fn classify_deletion_catalog_result(result: buzz_db::Result<()>) -> DeletionCata } #[async_trait::async_trait] -pub(crate) trait ReadinessEvaluator: Send + Sync { - async fn evaluate(&self, db: &Db, redis_pool: &deadpool_redis::Pool) -> ReadinessEvaluation; +pub(crate) trait DependencyEvaluator: Send + Sync { + async fn evaluate(&self, db: &Db, redis_pool: &deadpool_redis::Pool) -> DependencyReport; } -struct ProductionReadinessEvaluator; +struct ProductionDependencyEvaluator; #[async_trait::async_trait] -impl ReadinessEvaluator for ProductionReadinessEvaluator { - async fn evaluate(&self, db: &Db, redis_pool: &deadpool_redis::Pool) -> ReadinessEvaluation { - let deadline = Instant::now() + READINESS_TIMEOUT; +impl DependencyEvaluator for ProductionDependencyEvaluator { + async fn evaluate(&self, db: &Db, redis_pool: &deadpool_redis::Pool) -> DependencyReport { + let deadline = Instant::now() + DEPENDENCY_TIMEOUT; evaluate_dependencies( async { db.readiness_check(deadline).await.into() }, redis_check(redis_pool, deadline), @@ -364,150 +416,280 @@ impl ReadinessEvaluator for ProductionReadinessEvaluator { } } +/// One completed evaluation and when it was observed. #[derive(Debug, Clone, Copy)] -pub(crate) struct ProbeTicket { - generation: u64, +struct DependencySample { + report: DependencyReport, + observed_at: Instant, } +/// What the cache can tell `/_status`. +/// +/// "No report yet" is a distinct state, not a fabricated healthy one, and a +/// report is always accompanied by its age: a cached verdict presented without +/// one would read as authoritative however long ago it was taken. #[derive(Debug, Clone, Copy)] -pub(crate) enum ProbeStart { - Evaluate(ProbeTicket), - ShuttingDown, +pub(crate) enum DependencySnapshot { + /// The sampler has not completed its first evaluation yet. + NotYetSampled, + Sampled { + report: DependencyReport, + age: Duration, + stale: bool, + }, } -#[derive(Debug, Default)] -struct PublicationState { - next_generation: u64, - latest_published_generation: u64, - shutdown_generation: Option, -} - -/// Serializes readiness result publication with terminal process shutdown. -pub(crate) struct ReadinessCoordinator { - state: Mutex, - evaluator: Arc, -} - -impl Default for ReadinessCoordinator { - fn default() -> Self { - Self { - state: Mutex::new(PublicationState::default()), - evaluator: Arc::new(ProductionReadinessEvaluator), - } - } -} - -impl ReadinessCoordinator { - #[cfg(test)] - pub(crate) fn with_evaluator(evaluator: Arc) -> Self { - Self { - state: Mutex::new(PublicationState::default()), - evaluator, - } - } - - fn lock_state(&self) -> MutexGuard<'_, PublicationState> { - self.state.lock().unwrap_or_else(PoisonError::into_inner) - } - - pub(crate) async fn evaluate( - &self, - db: &Db, - redis_pool: &deadpool_redis::Pool, - ) -> ReadinessEvaluation { - self.evaluator.evaluate(db, redis_pool).await - } - - /// Allocates a health-probe generation or records a truthful shutdown fast path. - pub(crate) fn begin_probe(&self) -> ProbeStart { - let mut state = self.lock_state(); - if state.shutdown_generation.is_some() { - let evaluation = ReadinessEvaluation::shutting_down(); - record_attempt_metrics(&evaluation, ReadinessReason::ShuttingDown); - record_overall_state(false); - return ProbeStart::ShuttingDown; - } - - state.next_generation = state.next_generation.saturating_add(1); - ProbeStart::Evaluate(ProbeTicket { - generation: state.next_generation, - }) - } - - /// Commits one completed health probe through the shared publication fence. - pub(crate) fn finish_probe( - &self, - ticket: ProbeTicket, - evaluation: ReadinessEvaluation, - ) -> ReadinessEvaluation { - let mut state = self.lock_state(); - if state.shutdown_generation.is_some() { - record_attempt_metrics(&evaluation, ReadinessReason::ShuttingDown); - return ReadinessEvaluation::shutting_down(); - } - - record_attempt_metrics(&evaluation, evaluation.reason); - if ticket.generation > state.latest_published_generation { - record_current_state(&evaluation); - state.latest_published_generation = ticket.generation; - } - evaluation - } - - /// Returns whether a compatibility/public readiness evaluation may start. - pub(crate) fn public_evaluation_allowed(&self) -> bool { - self.lock_state().shutdown_generation.is_none() - } - - /// Makes shutdown dominate a public request that was already in flight. - pub(crate) fn finish_public_evaluation( - &self, - evaluation: ReadinessEvaluation, - ) -> ReadinessEvaluation { - if self.lock_state().shutdown_generation.is_some() { - ReadinessEvaluation::shutting_down() - } else { - evaluation - } - } - - /// Commits terminal shutdown and immediately publishes overall not-ready. - pub(crate) fn begin_shutdown(&self) { - let mut state = self.lock_state(); - if state.shutdown_generation.is_none() { - let generation = state.next_generation.saturating_add(1); - state.shutdown_generation = Some(generation); - record_overall_state(false); - } - } -} - -fn record_attempt_metrics(evaluation: &ReadinessEvaluation, reason: ReadinessReason) { - metrics::counter!( - "buzz_readiness_checks_total", - "reason" => reason.label(), +/// Republish cadence for the completion-epoch gauge. +/// +/// The configured gauge idle timeout comes from `main.rs`; this returns a +/// bounded cadence that is strictly below that timeout and never slower than +/// the dependency sampler itself. +pub fn dependency_sample_completion_republish_interval(gauge_idle_timeout_secs: u64) -> Duration { + let idle_timeout_secs = gauge_idle_timeout_secs.max(1); + let refresh_secs = idle_timeout_secs.saturating_div(3).max(1); + let strict_upper_bound_secs = idle_timeout_secs.saturating_sub(1).max(1); + Duration::from_secs( + refresh_secs + .min(strict_upper_bound_secs) + .min(DEPENDENCY_SAMPLE_COMPLETION_REPUBLISH_MAX_INTERVAL.as_secs()), ) - .increment(1); +} - if !evaluation.dependencies_ran() { - return; +/// The per-pod owner of shared-dependency evaluation. +/// +/// The production [`run_dependency_sampler`] loop is the sole runtime owner of +/// [`Self::sample`], so at most one evaluation exists at a time and no request +/// path can start another. The companion completion publisher only reads the +/// stored epoch, while `/_status` reads [`Self::snapshot`]; neither touches a +/// dependency. +pub(crate) struct DependencyDiagnostics { + evaluator: Arc, + latest: Mutex>, + sample_completion_epoch_seconds: AtomicU64, + sample_completion_written: AtomicBool, +} + +impl Default for DependencyDiagnostics { + fn default() -> Self { + Self::with_evaluator(Arc::new(ProductionDependencyEvaluator)) + } +} + +impl DependencyDiagnostics { + pub(crate) fn with_evaluator(evaluator: Arc) -> Self { + Self { + evaluator, + latest: Mutex::new(None), + sample_completion_epoch_seconds: AtomicU64::new(0), + sample_completion_written: AtomicBool::new(false), + } } + /// Runs one bounded evaluation, publishes its telemetry, and replaces the + /// cached report. + /// + /// The completion timestamp is written last, once the cache already serves + /// this report, so the gauge can never describe a sample `/_status` is not + /// yet answering with. + pub(crate) async fn sample(&self, db: &Db, redis_pool: &deadpool_redis::Pool) { + let report = self.evaluator.evaluate(db, redis_pool).await; + record_dependency_report(&report); + let sample = DependencySample { + report, + observed_at: Instant::now(), + }; + *self.latest.lock().unwrap_or_else(PoisonError::into_inner) = Some(sample); + self.record_dependency_sample_completion(SystemTime::now()); + } + + /// The latest completed evaluation with its age. Starts no dependency work. + pub(crate) fn snapshot(&self) -> DependencySnapshot { + let latest = *self.latest.lock().unwrap_or_else(PoisonError::into_inner); + match latest { + None => DependencySnapshot::NotYetSampled, + Some(sample) => { + let age = sample.observed_at.elapsed(); + DependencySnapshot::Sampled { + report: sample.report, + age, + stale: age > DEPENDENCY_SAMPLE_STALE_AFTER, + } + } + } + } + + fn record_dependency_sample_completion(&self, completed_at: SystemTime) { + let epoch_seconds = dependency_sample_completion_epoch_seconds(completed_at); + self.sample_completion_epoch_seconds + .store(epoch_seconds, Ordering::Release); + self.sample_completion_written + .store(true, Ordering::Release); + publish_dependency_sample_completion_metric(epoch_seconds); + } + + fn latest_sample_completion_epoch_seconds(&self) -> Option { + self.sample_completion_written + .load(Ordering::Acquire) + .then(|| self.sample_completion_epoch_seconds.load(Ordering::Acquire)) + } + + /// Re-emits the stored epoch, then checks it is still the stored one. + /// + /// A sample can complete and publish a newer epoch while this write is in + /// flight, and the metrics facade exposes only a bare `set`, so nothing + /// downstream rejects the older value once it lands last. Verifying after + /// the write — rather than before it — is what closes that window: a + /// republish that lost the race re-emits the newer epoch instead of + /// leaving the exported series moved backwards. Another pass costs another + /// completed sample, so this ends as soon as no completion is racing it. + fn republish_dependency_sample_completion(&self) { + let mut published = None; + while let Some(epoch_seconds) = self.latest_sample_completion_epoch_seconds() { + if published == Some(epoch_seconds) { + break; + } + publish_dependency_sample_completion_metric(epoch_seconds); + published = Some(epoch_seconds); + } + } +} + +/// Runs the per-pod dependency sampling loop until `cancel` fires. +/// +/// One loop, one fixed cadence, each evaluation awaited before the next tick is +/// taken, so this pod never has two evaluations in flight. `Skip` matches the +/// community revalidator: an evaluation that overruns its slot delays the next +/// cycle instead of queueing a catch-up burst into the dependency that was +/// already slow. The first tick fires immediately, so the not-yet-sampled +/// window is one evaluation long. +pub async fn run_dependency_sampler(state: Arc, cancel: CancellationToken) { + run_dependency_sampler_for_diagnostics( + Arc::clone(&state.dependency_diagnostics), + state.db.clone(), + state.redis_pool.clone(), + DEPENDENCY_SAMPLE_INTERVAL, + cancel, + ) + .await; +} + +/// Starts the dependency sampler and completion republisher together. +/// +/// Main calls this once at startup so sampler and publisher ownership lives at +/// one seam instead of being wired independently. +pub fn start_dependency_sampler_and_completion_publisher( + state: Arc, + republish_interval: Duration, +) { + let sampler_cancel = state.dependency_sampler_cancel.clone(); + tokio::spawn(run_dependency_sampler(Arc::clone(&state), sampler_cancel)); + + let publisher_cancel = state.dependency_completion_publisher_cancel.clone(); + tokio::spawn(run_dependency_sample_completion_publisher( + state, + republish_interval, + publisher_cancel, + )); +} + +async fn run_dependency_sampler_for_diagnostics( + diagnostics: Arc, + db: Db, + redis_pool: deadpool_redis::Pool, + sample_interval: Duration, + cancel: CancellationToken, +) { + let mut interval = tokio::time::interval(sample_interval.max(Duration::from_millis(1))); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + biased; + _ = cancel.cancelled() => break, + _ = interval.tick() => { + diagnostics.sample(&db, &redis_pool).await; + } + } + } +} + +/// Re-emits the latest completion epoch so the gauge survives recorder idle +/// eviction even when no newer dependency sample completes. +pub async fn run_dependency_sample_completion_publisher( + state: Arc, + republish_interval: Duration, + cancel: CancellationToken, +) { + run_dependency_sample_completion_publisher_for_diagnostics( + Arc::clone(&state.dependency_diagnostics), + republish_interval, + cancel, + ) + .await; +} + +async fn run_dependency_sample_completion_publisher_for_diagnostics( + diagnostics: Arc, + republish_interval: Duration, + cancel: CancellationToken, +) { + let mut interval = tokio::time::interval(republish_interval.max(Duration::from_millis(1))); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + biased; + _ = cancel.cancelled() => break, + _ = interval.tick() => diagnostics.republish_dependency_sample_completion(), + } + } +} + +/// Records one dependency evaluation. Counters and durations only — dependency +/// health has no publishable "current state" now that no probe consumes it; a +/// per-dependency gauge would read as an authoritative verdict on infrastructure +/// this pod only samples every [`DEPENDENCY_SAMPLE_INTERVAL`]. +fn record_dependency_report(report: &DependencyReport) { metrics::histogram!( "buzz_readiness_check_duration_seconds", "check" => "overall", ) - .record(evaluation.total_duration.as_secs_f64()); + .record(report.total_duration.as_secs_f64()); - if let Some(result) = evaluation.postgres { - record_dependency_attempt("postgres", result.outcome.label(), result.duration); - } - if let Some(result) = evaluation.redis { - record_dependency_attempt("redis", result.outcome.label(), result.duration); - } - if let Some(result) = evaluation.deletion_catalog { - record_dependency_attempt("deletion_catalog", result.outcome.label(), result.duration); - } + record_dependency_attempt( + "postgres", + report.postgres.outcome.label(), + report.postgres.duration, + ); + record_dependency_attempt("redis", report.redis.outcome.label(), report.redis.duration); + record_dependency_attempt( + "deletion_catalog", + report.deletion_catalog.outcome.label(), + report.deletion_catalog.duration, + ); +} + +/// Publishes the Unix time the cached report completed. +/// +/// A completion timestamp rather than an age, because age then belongs to the +/// query — `time() - buzz_readiness_dependency_sample_completed_timestamp_seconds` +/// — and grows on its own while this pod is wedged. A gauge carrying the age +/// needs a writer to advance it, so the one failure it most needs to expose, a +/// sampler that stopped running, is the one that would freeze it at its last +/// value and read as permanently fresh. A completed sample is the only thing +/// that may advance this epoch; the independent publisher only re-emits the +/// stored value so the series survives gauge idle-eviction. This keeps the +/// `buzz_storage_sweep_age_seconds` convention that absence means +/// "not yet sampled" rather than "fresh". +/// +fn dependency_sample_completion_epoch_seconds(completed_at: SystemTime) -> u64 { + completed_at + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn publish_dependency_sample_completion_metric(epoch_seconds: u64) { + metrics::gauge!("buzz_readiness_dependency_sample_completed_timestamp_seconds") + .set(epoch_seconds as f64); } fn record_dependency_attempt(dependency: &'static str, outcome: &'static str, duration: Duration) { @@ -524,35 +706,6 @@ fn record_dependency_attempt(dependency: &'static str, outcome: &'static str, du .record(duration.as_secs_f64()); } -fn record_current_state(evaluation: &ReadinessEvaluation) { - record_overall_state(evaluation.is_ready()); - if let Some(result) = evaluation.postgres { - record_dependency_state("postgres", result.outcome.is_success()); - } - if let Some(result) = evaluation.redis { - record_dependency_state("redis", result.outcome.is_success()); - } - if let Some(result) = evaluation.deletion_catalog { - record_dependency_state("deletion_catalog", result.outcome.is_success()); - } -} - -fn record_overall_state(ready: bool) { - metrics::gauge!("buzz_readiness_state", "check" => "overall").set(if ready { - 1.0 - } else { - 0.0 - }); -} - -fn record_dependency_state(dependency: &'static str, ready: bool) { - metrics::gauge!("buzz_readiness_state", "check" => dependency).set(if ready { - 1.0 - } else { - 0.0 - }); -} - #[cfg(test)] mod tests { use metrics_util::debugging::{DebugValue, DebuggingRecorder}; @@ -560,17 +713,15 @@ mod tests { use super::*; - fn ready_evaluation() -> ReadinessEvaluation { - ReadinessEvaluation::from_results( - TimedOutcome::new(PostgresOutcome::Success, Duration::from_millis(35)), - TimedOutcome::new(RedisOutcome::Success, Duration::from_millis(10)), - TimedOutcome::new(DeletionCatalogOutcome::Success, Duration::from_millis(20)), - Duration::from_millis(35), - ) - } + type Snapshot = Vec<( + CompositeKey, + Option, + Option, + DebugValue, + )>; - fn redis_failure_evaluation() -> ReadinessEvaluation { - ReadinessEvaluation::from_results( + fn redis_failure_report() -> DependencyReport { + DependencyReport::from_results( TimedOutcome::new(PostgresOutcome::Success, Duration::from_millis(35)), TimedOutcome::new(RedisOutcome::PoolTimeout, Duration::from_secs(2)), TimedOutcome::new(DeletionCatalogOutcome::Success, Duration::from_millis(20)), @@ -579,12 +730,7 @@ mod tests { } fn exact_metric<'a>( - snapshot: &'a [( - CompositeKey, - Option, - Option, - DebugValue, - )], + snapshot: &'a Snapshot, name: &str, labels: &[(&str, &str)], ) -> Option<&'a DebugValue> { @@ -601,15 +747,7 @@ mod tests { }) } - fn gauge_value( - snapshot: &[( - CompositeKey, - Option, - Option, - DebugValue, - )], - check: &str, - ) -> f64 { + fn gauge_value(snapshot: &Snapshot, check: &str) -> f64 { let value = exact_metric(snapshot, "buzz_readiness_state", &[("check", check)]) .expect("readiness gauge"); let DebugValue::Gauge(value) = value else { @@ -620,7 +758,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn evaluation_preserves_a_completed_check_when_another_times_out() { - let evaluation = evaluate_dependencies( + let report = evaluate_dependencies( async { tokio::time::sleep(Duration::from_millis(35)).await; PostgresOutcome::Success @@ -636,15 +774,9 @@ mod tests { ) .await; - assert_eq!(evaluation.reason, ReadinessReason::RedisPoolTimeout); - assert_eq!( - evaluation.postgres.map(|result| result.duration), - Some(Duration::from_millis(35)) - ); - assert_eq!( - evaluation.redis.map(|result| result.duration), - Some(Duration::from_secs(2)) - ); + assert_eq!(report.reason, DependencyReason::RedisPoolTimeout); + assert_eq!(report.postgres.duration, Duration::from_millis(35)); + assert_eq!(report.redis.duration, Duration::from_secs(2)); } #[test] @@ -655,7 +787,7 @@ mod tests { RedisOutcome::PoolTimeout, DeletionCatalogOutcome::Success, ), - ReadinessReason::OverallTimeout + DependencyReason::OverallTimeout ); } @@ -696,7 +828,7 @@ mod tests { .map(DeletionCatalogOutcome::label), ["success", "operation_timeout", "operation_error"] ); - assert_eq!(READINESS_RAW_SERIES_PER_POD, 99); + assert_eq!(READINESS_RAW_SERIES_PER_POD, 87); } #[test] @@ -712,110 +844,76 @@ mod tests { } #[test] - fn slow_older_failure_cannot_overwrite_newer_success_gauges() { - let coordinator = ReadinessCoordinator::default(); - let ProbeStart::Evaluate(slow_a) = coordinator.begin_probe() else { - panic!("serving probe A"); - }; - let ProbeStart::Evaluate(fast_b) = coordinator.begin_probe() else { - panic!("serving probe B"); - }; + fn completion_timestamp_republish_interval_stays_below_idle_timeout() { + assert_eq!( + dependency_sample_completion_republish_interval(900), + DEPENDENCY_SAMPLE_INTERVAL, + "default idle timeout should republish on the sampler cadence" + ); + assert_eq!( + dependency_sample_completion_republish_interval(15), + Duration::from_secs(5), + "the minimum configured idle timeout must still get multiple republishes" + ); + assert!(dependency_sample_completion_republish_interval(15) < Duration::from_secs(15)); + } + + /// The readiness gauge and counter use the same immutable reason sampled by + /// the private probe. A dependency evaluation — however bad — must never + /// move them, which is what let a shared outage deroute every replica at + /// once. + #[test] + fn readiness_telemetry_tracks_lifecycle_and_dependency_failure_never_moves_it() { let recorder = DebuggingRecorder::new(); let snapshotter = recorder.snapshotter(); metrics::with_local_recorder(&recorder, || { - coordinator.finish_probe(fast_b, ready_evaluation()); - coordinator.finish_probe(slow_a, redis_failure_evaluation()); + record_readiness_probe(ReadinessReason::Ready); + record_dependency_report(&redis_failure_report()); }); - let snapshot = snapshotter.snapshot().into_vec(); + let after_failure = snapshotter.snapshot().into_vec(); - assert_eq!(gauge_value(&snapshot, "overall"), 1.0); - assert_eq!(gauge_value(&snapshot, "redis"), 1.0); + assert_eq!(gauge_value(&after_failure, "overall"), 1.0); assert!(matches!( exact_metric( - &snapshot, + &after_failure, "buzz_readiness_checks_total", &[("reason", "ready")] ), Some(DebugValue::Counter(1)) )); - assert!(matches!( - exact_metric( - &snapshot, - "buzz_readiness_checks_total", - &[("reason", "redis_pool_timeout")] - ), - Some(DebugValue::Counter(1)) - )); - } - - #[test] - fn slow_older_success_cannot_overwrite_newer_failure_gauges() { - let coordinator = ReadinessCoordinator::default(); - let ProbeStart::Evaluate(slow_a) = coordinator.begin_probe() else { - panic!("serving probe A"); - }; - let ProbeStart::Evaluate(fast_b) = coordinator.begin_probe() else { - panic!("serving probe B"); - }; - let recorder = DebuggingRecorder::new(); - let snapshotter = recorder.snapshotter(); - - metrics::with_local_recorder(&recorder, || { - coordinator.finish_probe(fast_b, redis_failure_evaluation()); - coordinator.finish_probe(slow_a, ready_evaluation()); - }); - let snapshot = snapshotter.snapshot().into_vec(); - - assert_eq!(gauge_value(&snapshot, "overall"), 0.0); - assert_eq!(gauge_value(&snapshot, "postgres"), 1.0); - assert_eq!(gauge_value(&snapshot, "redis"), 0.0); - assert_eq!(gauge_value(&snapshot, "deletion_catalog"), 1.0); - } - - #[test] - fn shutdown_fast_path_preserves_dependency_state_and_histograms() { - let coordinator = ReadinessCoordinator::default(); - let recorder = DebuggingRecorder::new(); - let snapshotter = recorder.snapshotter(); - - metrics::with_local_recorder(&recorder, || { - let ProbeStart::Evaluate(ticket) = coordinator.begin_probe() else { - panic!("initial serving probe"); - }; - coordinator.finish_probe(ticket, ready_evaluation()); - coordinator.begin_shutdown(); - assert!(matches!( - coordinator.begin_probe(), - ProbeStart::ShuttingDown - )); - }); - let after = snapshotter.snapshot().into_vec(); - - for dependency in ["postgres", "redis", "deletion_catalog"] { - assert_eq!( - gauge_value(&after, dependency), - 1.0, - "shutdown must not fabricate {dependency} state" - ); - } - for check in ["overall", "postgres", "redis", "deletion_catalog"] { - assert!( - matches!( - exact_metric( - &after, - "buzz_readiness_check_duration_seconds", - &[("check", check)] - ), - Some(DebugValue::Histogram(values)) if values.len() == 1 + assert!( + matches!( + exact_metric( + &after_failure, + "buzz_readiness_dependency_checks_total", + &[("dependency", "redis"), ("outcome", "pool_timeout")] ), - "shutdown fast path must not add a {check} duration" + Some(DebugValue::Counter(1)) + ), + "dependency diagnostics must still be counted" + ); + for dependency in ["postgres", "redis", "deletion_catalog"] { + assert!( + exact_metric( + &after_failure, + "buzz_readiness_state", + &[("check", dependency)] + ) + .is_none(), + "{dependency} must not publish a readiness gauge" ); } - assert_eq!(gauge_value(&after, "overall"), 0.0); + + metrics::with_local_recorder(&recorder, || { + record_readiness_probe(ReadinessReason::ShuttingDown); + }); + let after_shutdown = snapshotter.snapshot().into_vec(); + + assert_eq!(gauge_value(&after_shutdown, "overall"), 0.0); assert!(matches!( exact_metric( - &after, + &after_shutdown, "buzz_readiness_checks_total", &[("reason", "shutting_down")] ), @@ -823,33 +921,630 @@ mod tests { )); } + /// A shutdown probe records no dependency attempt or latency sample: it did + /// not evaluate anything, and fabricating a sample would misreport the + /// dependency's real health during a rollout. #[test] - fn shutdown_dominates_an_in_flight_success_without_resurrecting_gauges() { - let coordinator = ReadinessCoordinator::default(); - let ProbeStart::Evaluate(ticket) = coordinator.begin_probe() else { - panic!("serving probe"); - }; + fn a_readiness_probe_never_records_dependency_attempts() { let recorder = DebuggingRecorder::new(); let snapshotter = recorder.snapshotter(); - let response = metrics::with_local_recorder(&recorder, || { - coordinator.begin_shutdown(); - coordinator.finish_probe(ticket, ready_evaluation()) + metrics::with_local_recorder(&recorder, || { + record_readiness_probe(ReadinessReason::Ready); + record_readiness_probe(ReadinessReason::ShuttingDown); }); let snapshot = snapshotter.snapshot().into_vec(); - assert_eq!(response.reason, ReadinessReason::ShuttingDown); - assert_eq!(gauge_value(&snapshot, "overall"), 0.0); + assert!(snapshot.iter().all(|(key, _, _, _)| { + key.key().name() != "buzz_readiness_dependency_checks_total" + && key.key().name() != "buzz_readiness_check_duration_seconds" + })); + } + + /// A `Db` and a Redis pool on a closed port. The scripted evaluators below + /// never touch either, so no connection is ever attempted; they exist only + /// to satisfy the production `sample` signature. + fn unreachable_dependencies() -> (Db, deadpool_redis::Pool) { + let pool = sqlx::PgPool::connect_lazy("postgres://127.0.0.1:1/buzz").expect("lazy pg pool"); + let redis_pool = deadpool_redis::Config::from_url("redis://127.0.0.1:1") + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + (Db::from_pool(pool), redis_pool) + } + + struct FixedEvaluator(DependencyReport); + + #[async_trait::async_trait] + impl DependencyEvaluator for FixedEvaluator { + async fn evaluate(&self, _db: &Db, _redis_pool: &deadpool_redis::Pool) -> DependencyReport { + self.0 + } + } + + struct DelayedEvaluator { + report: DependencyReport, + started: Arc, + release: Arc, + } + + #[async_trait::async_trait] + impl DependencyEvaluator for DelayedEvaluator { + async fn evaluate(&self, _db: &Db, _redis_pool: &deadpool_redis::Pool) -> DependencyReport { + self.started.notify_one(); + self.release.notified().await; + self.report + } + } + + fn ready_report() -> DependencyReport { + DependencyReport::from_results( + TimedOutcome::new(PostgresOutcome::Success, Duration::from_millis(3)), + TimedOutcome::new(RedisOutcome::Success, Duration::from_millis(2)), + TimedOutcome::new(DeletionCatalogOutcome::Success, Duration::from_millis(1)), + Duration::from_millis(3), + ) + } + + fn ready_diagnostics() -> DependencyDiagnostics { + DependencyDiagnostics::with_evaluator(Arc::new(FixedEvaluator(ready_report()))) + } + + fn sampled(snapshot: DependencySnapshot) -> (Duration, bool) { + let DependencySnapshot::Sampled { age, stale, .. } = snapshot else { + panic!("a completed sample must be reported as sampled"); + }; + (age, stale) + } + + /// An operator reading `/_status` must be able to tell "nothing has been + /// sampled yet" from "this is current" from "this outlived the sampler". + /// A cached report presented without its age would read as authoritative + /// however old it is. + #[tokio::test(start_paused = true)] + async fn freshness_separates_not_yet_sampled_from_a_fresh_and_a_stale_report() { + let (db, redis_pool) = unreachable_dependencies(); + let diagnostics = ready_diagnostics(); + assert!( - exact_metric(&snapshot, "buzz_readiness_state", &[("check", "postgres")]).is_none() + matches!(diagnostics.snapshot(), DependencySnapshot::NotYetSampled), + "no evaluation has completed, so there is nothing to report" ); - assert!(matches!( - exact_metric( - &snapshot, - "buzz_readiness_dependency_checks_total", - &[("dependency", "postgres"), ("outcome", "success")] + + diagnostics.sample(&db, &redis_pool).await; + assert_eq!(sampled(diagnostics.snapshot()), (Duration::ZERO, false)); + + tokio::time::advance(DEPENDENCY_SAMPLE_INTERVAL).await; + assert_eq!( + sampled(diagnostics.snapshot()), + (DEPENDENCY_SAMPLE_INTERVAL, false), + "one cadence of age is the steady state, not staleness" + ); + + tokio::time::advance(DEPENDENCY_SAMPLE_INTERVAL + Duration::from_secs(1)).await; + let (age, stale) = sampled(diagnostics.snapshot()); + assert_eq!(age, DEPENDENCY_SAMPLE_INTERVAL * 2 + Duration::from_secs(1)); + assert!(stale, "a report that outlived two cadences missed a cycle"); + } + + /// The completion timestamp written since the previous snapshot, if any. + /// + /// `Snapshotter::snapshot` drains, so a window in which nothing wrote this + /// metric either omits the key entirely or, once registered, replays as + /// `0`. Zero is not a time any sample could have completed at, so folding + /// it into `None` keeps "nobody wrote this in that window" expressible — + /// the property a completion timestamp must have and an age cannot. + fn sample_completion_metric_write(snapshot: &Snapshot) -> Option { + exact_metric( + snapshot, + "buzz_readiness_dependency_sample_completed_timestamp_seconds", + &[], + ) + .map(|value| { + let DebugValue::Gauge(value) = value else { + panic!("the sample completion timestamp must be a gauge"); + }; + value.into_inner() as u64 + }) + .filter(|written| *written != 0) + } + + fn epoch_seconds_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("the system clock is after the Unix epoch") + .as_secs() + } + + fn sample_completion_metric_value_from_scrape(scrape: &str) -> Option { + scrape.lines().find_map(|line| { + line.strip_prefix("buzz_readiness_dependency_sample_completed_timestamp_seconds ") + .and_then(|value| value.parse::().ok()) + .map(|value| value as u64) + }) + } + + fn sample_completion_metric_type_from_scrape(scrape: &str) -> Option<&str> { + scrape.lines().find_map(|line| { + line.strip_prefix( + "# TYPE buzz_readiness_dependency_sample_completed_timestamp_seconds ", + ) + }) + } + + /// Minimal model of Datadog OpenMetrics v2 with `send_monotonic_counter:true`. + /// + /// Gauge values are forwarded as-is. Counter values become per-scrape deltas + /// (`monotonic_count` / `.count`), so the exported sample itself is no + /// longer available to monitor queries. + fn datadog_openmetrics_v2_completion_epoch( + scrape: &str, + previous_counter_raw: &mut Option, + ) -> Option { + let raw = sample_completion_metric_value_from_scrape(scrape)?; + match sample_completion_metric_type_from_scrape(scrape) { + Some("gauge") => Some(raw), + Some("counter") => { + let transformed = previous_counter_raw + .map(|previous| raw.saturating_sub(previous)) + .unwrap_or(raw); + *previous_counter_raw = Some(raw); + Some(transformed) + } + Some(other) => panic!("unexpected metric type: {other}"), + None => panic!("missing sample completion metric type"), + } + } + + /// The scrape-side half of the same question. Freshness is published as the + /// Unix time the cached report completed, written only by a completed + /// sample, so the age belongs to the query + /// (`time() - buzz_readiness_dependency_sample_completed_timestamp_seconds`) + /// and grows on its own while this pod is wedged. A gauge carrying the age + /// instead would need a writer to advance it, so the one failure it most + /// needs to expose — a sampler that stopped — is the one that would freeze + /// it at its last value and read as permanently fresh. + #[test] + fn the_completion_timestamp_gauge_tracks_completed_samples() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .start_paused(true) + .build() + .expect("paused current-thread runtime"); + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + + metrics::with_local_recorder(&recorder, || { + runtime.block_on(async { + let (db, redis_pool) = unreachable_dependencies(); + let diagnostics = ready_diagnostics(); + + assert_eq!( + sample_completion_metric_write(&snapshotter.snapshot().into_vec()), + None, + "absence is the not-yet-sampled signal, not a fresh zero" + ); + + let before_first = epoch_seconds_now(); + diagnostics.sample(&db, &redis_pool).await; + let after_first = epoch_seconds_now(); + let first = sample_completion_metric_write(&snapshotter.snapshot().into_vec()) + .expect("a completed sample must publish when it completed"); + assert!( + (before_first..=after_first).contains(&first), + "{first} must be the wall time the sample completed, \ + not an age, and not a stale reading" + ); + + // Paused time: no task, timer, or aging loop can run here. + tokio::time::advance(DEPENDENCY_SAMPLE_STALE_AFTER + Duration::from_secs(1)).await; + assert_eq!( + sample_completion_metric_write(&snapshotter.snapshot().into_vec()), + None, + "only a completed sample may write the gauge, so the age a \ + query derives from it grows with no server-side writer" + ); + assert!( + sampled(diagnostics.snapshot()).1, + "the cached `/_status` age reports the same report as stale" + ); + + let before_second = epoch_seconds_now(); + diagnostics.sample(&db, &redis_pool).await; + let after_second = epoch_seconds_now(); + let second = sample_completion_metric_write(&snapshotter.snapshot().into_vec()) + .expect("the next completed sample republishes the timestamp"); + assert!( + (before_second..=after_second).contains(&second) && second >= first, + "{second} must re-anchor to the second completion, after {first}" + ); + assert!( + !sampled(diagnostics.snapshot()).1, + "a fresh completion clears staleness" + ); + }); + }); + } + + #[test] + fn openmetrics_v2_preserves_epoch_only_when_raw_prometheus_type_is_gauge() { + let (recorder, handle) = crate::metrics::readiness_test_recorder(); + let mut previous_counter_raw = None; + + assert_eq!( + datadog_openmetrics_v2_completion_epoch(&handle.render(), &mut previous_counter_raw), + None, + "absence before the first completion remains absence after transform" + ); + + let first = 4_750_000_001_u64; + metrics::with_local_recorder(&recorder, || { + publish_dependency_sample_completion_metric(first); + }); + let after_first_completion = handle.render(); + assert_eq!( + datadog_openmetrics_v2_completion_epoch( + &after_first_completion, + &mut previous_counter_raw, ), - Some(DebugValue::Counter(1)) - )); + Some(first), + "after first completion the transformed value must be the completion epoch" + ); + + // Same raw scrape value after idle timeout: sampler produced no new completion. + assert_eq!( + datadog_openmetrics_v2_completion_epoch( + &after_first_completion, + &mut previous_counter_raw, + ), + Some(first), + "idle-timeout survival still must preserve the full epoch" + ); + + let second = 4_750_000_005_u64; + metrics::with_local_recorder(&recorder, || { + publish_dependency_sample_completion_metric(second); + }); + let after_later_completion = handle.render(); + assert_eq!( + datadog_openmetrics_v2_completion_epoch( + &after_later_completion, + &mut previous_counter_raw, + ), + Some(second), + "a later completion must transform to the new full epoch" + ); + + // Same raw value again while only the sampler is stopped. + assert_eq!( + datadog_openmetrics_v2_completion_epoch( + &after_later_completion, + &mut previous_counter_raw, + ), + Some(second), + "sampler stoppage must not collapse the epoch to a delta" + ); + } + + async fn wait_for_sample_completion_scrape_value( + handle: &metrics_exporter_prometheus::PrometheusHandle, + predicate: impl Fn(u64) -> bool, + ) -> u64 { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + if let Some(value) = sample_completion_metric_value_from_scrape(&handle.render()) { + if predicate(value) { + return value; + } + } + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for completion timestamp scrape value" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } + } + + #[test] + fn dependency_runtime_retains_the_completion_timestamp_when_only_sampler_stops() { + let timeout = Duration::from_secs(1); + let republish_interval = Duration::from_millis(100); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .start_paused(true) + .build() + .expect("current-thread runtime"); + let (recorder, handle) = + crate::metrics::readiness_test_recorder_with_idle_timeout(timeout.as_secs()); + + metrics::with_local_recorder(&recorder, || { + runtime.block_on(async { + let mut state = crate::state::tests::test_state_with_database_url( + "postgres://127.0.0.1:1/buzz", + ) + .await; + let sample_started = Arc::new(tokio::sync::Notify::new()); + let release_sample = Arc::new(tokio::sync::Notify::new()); + Arc::get_mut(&mut state) + .expect("sole readiness state") + .set_dependency_evaluator(Arc::new(DelayedEvaluator { + report: ready_report(), + started: Arc::clone(&sample_started), + release: Arc::clone(&release_sample), + })); + + start_dependency_sampler_and_completion_publisher( + Arc::clone(&state), + republish_interval, + ); + + tokio::time::timeout(Duration::from_secs(5), sample_started.notified()) + .await + .expect("dependency sampler must start its first sample within 5 seconds"); + for _ in 0..3 { + tokio::time::advance(republish_interval).await; + tokio::task::yield_now().await; + } + assert_eq!( + sample_completion_metric_value_from_scrape(&handle.render()), + None, + "republisher ticks must not fabricate an epoch before the first completion" + ); + + release_sample.notify_one(); + + let first = + wait_for_sample_completion_scrape_value(&handle, |value| value > 0).await; + + state.dependency_sampler_cancel.cancel(); + tokio::time::resume(); + + tokio::time::sleep(timeout + Duration::from_millis(200)).await; + assert_eq!( + sample_completion_metric_value_from_scrape(&handle.render()), + Some(first), + "publisher must keep the completion epoch exported after idle timeout" + ); + + tokio::time::sleep(timeout + Duration::from_millis(200)).await; + assert_eq!( + sample_completion_metric_value_from_scrape(&handle.render()), + Some(first), + "with no sampler activity, publisher must keep exporting the stored epoch" + ); + + state.dependency_completion_publisher_cancel.cancel(); + }); + }); + } + + /// How long a park step waits before it is a failure rather than a hang. + const PARK_SIGNAL_TIMEOUT: Duration = Duration::from_secs(10); + + /// A completion-epoch gauge write that can be stopped at the recorder + /// boundary — the last instruction of a publish, after the writer has + /// already read the epoch it is publishing. + /// + /// Arming names one epoch value and fires once, so the sampler's own write + /// passes straight through and only the republish under test parks. + struct PublishPark { + armed_for: Mutex>, + parked_tx: std::sync::mpsc::SyncSender<()>, + parked_rx: Mutex>, + release_tx: std::sync::mpsc::SyncSender<()>, + release_rx: Mutex>, + } + + impl PublishPark { + fn new() -> Self { + let (parked_tx, parked_rx) = std::sync::mpsc::sync_channel(1); + let (release_tx, release_rx) = std::sync::mpsc::sync_channel(1); + Self { + armed_for: Mutex::new(None), + parked_tx, + parked_rx: Mutex::new(parked_rx), + release_tx, + release_rx: Mutex::new(release_rx), + } + } + + fn arm(&self, epoch_seconds: u64) { + *self.armed_for.lock().expect("park state") = Some(epoch_seconds); + } + + fn park_if_armed(&self, epoch_seconds: u64) { + { + let mut armed = self.armed_for.lock().expect("park state"); + if *armed != Some(epoch_seconds) { + return; + } + *armed = None; + } + self.parked_tx.send(()).expect("announce the parked write"); + self.release_rx + .lock() + .expect("release channel") + .recv_timeout(PARK_SIGNAL_TIMEOUT) + .expect("the parked write must be released"); + } + + fn wait_until_parked(&self) { + self.parked_rx + .lock() + .expect("park channel") + .recv_timeout(PARK_SIGNAL_TIMEOUT) + .expect("the armed write must reach the recorder"); + } + + fn release(&self) { + self.release_tx.send(()).expect("release the parked write"); + } + } + + /// Delegates to the real Prometheus recorder, with the completion-epoch + /// gauge routed through [`PublishPark`]. + struct ParkingRecorder { + inner: metrics_exporter_prometheus::PrometheusRecorder, + park: Arc, + } + + struct ParkingGauge { + inner: metrics::Gauge, + park: Arc, + } + + impl metrics::GaugeFn for ParkingGauge { + fn increment(&self, value: f64) { + self.inner.increment(value); + } + + fn decrement(&self, value: f64) { + self.inner.decrement(value); + } + + fn set(&self, value: f64) { + self.park.park_if_armed(value as u64); + self.inner.set(value); + } + } + + impl metrics::Recorder for ParkingRecorder { + fn describe_counter( + &self, + key: metrics::KeyName, + unit: Option, + description: metrics::SharedString, + ) { + self.inner.describe_counter(key, unit, description); + } + + fn describe_gauge( + &self, + key: metrics::KeyName, + unit: Option, + description: metrics::SharedString, + ) { + self.inner.describe_gauge(key, unit, description); + } + + fn describe_histogram( + &self, + key: metrics::KeyName, + unit: Option, + description: metrics::SharedString, + ) { + self.inner.describe_histogram(key, unit, description); + } + + fn register_counter( + &self, + key: &metrics::Key, + metadata: &metrics::Metadata<'_>, + ) -> metrics::Counter { + self.inner.register_counter(key, metadata) + } + + fn register_gauge( + &self, + key: &metrics::Key, + metadata: &metrics::Metadata<'_>, + ) -> metrics::Gauge { + let gauge = self.inner.register_gauge(key, metadata); + if key.name() == "buzz_readiness_dependency_sample_completed_timestamp_seconds" { + metrics::Gauge::from_arc(Arc::new(ParkingGauge { + inner: gauge, + park: Arc::clone(&self.park), + })) + } else { + gauge + } + } + + fn register_histogram( + &self, + key: &metrics::Key, + metadata: &metrics::Metadata<'_>, + ) -> metrics::Histogram { + self.inner.register_histogram(key, metadata) + } + } + + /// Two tasks write this gauge — the sampler, which owns the epoch, and the + /// idle-refresh publisher, which may only re-emit it — and the metrics + /// facade offers a bare `set` with no compare-and-set to lean on. So a + /// republish that read the stored epoch before a sample completed can still + /// be inside its own write when the newer epoch lands, and plain last-write + /// -wins would leave the exported series moved backwards until the next + /// republish tick. + /// + /// This parks the republish at the recorder, the last point in its write, + /// completes a newer sample behind it — which must not be blocked by the + /// parked republish — and only then releases it. The scrape must report the + /// newer completion. Times are injected so the two epochs are exact and the + /// interleaving does not depend on the wall clock. + #[test] + fn a_republish_racing_a_completion_cannot_move_the_exported_epoch_backwards() { + const EARLIER_EPOCH_SECONDS: u64 = 1_700_000_000; + const LATER_EPOCH_SECONDS: u64 = 1_700_000_030; + + let (prometheus, handle) = crate::metrics::readiness_test_recorder(); + let park = Arc::new(PublishPark::new()); + let recorder = Arc::new(ParkingRecorder { + inner: prometheus, + park: Arc::clone(&park), + }); + let diagnostics = Arc::new(ready_diagnostics()); + + metrics::with_local_recorder(recorder.as_ref(), || { + diagnostics.record_dependency_sample_completion( + SystemTime::UNIX_EPOCH + Duration::from_secs(EARLIER_EPOCH_SECONDS), + ); + }); + assert_eq!( + sample_completion_metric_value_from_scrape(&handle.render()), + Some(EARLIER_EPOCH_SECONDS), + "the first completion owns the epoch" + ); + + park.arm(EARLIER_EPOCH_SECONDS); + let republisher = std::thread::spawn({ + let recorder = Arc::clone(&recorder); + let diagnostics = Arc::clone(&diagnostics); + move || { + metrics::with_local_recorder(recorder.as_ref(), || { + diagnostics.republish_dependency_sample_completion(); + }); + } + }); + + park.wait_until_parked(); + + metrics::with_local_recorder(recorder.as_ref(), || { + diagnostics.record_dependency_sample_completion( + SystemTime::UNIX_EPOCH + Duration::from_secs(LATER_EPOCH_SECONDS), + ); + }); + assert_eq!( + sample_completion_metric_value_from_scrape(&handle.render()), + Some(LATER_EPOCH_SECONDS), + "a completed sample must publish its epoch while a republish is still in flight" + ); + + park.release(); + republisher.join().expect("republish thread"); + + assert_eq!( + sample_completion_metric_value_from_scrape(&handle.render()), + Some(LATER_EPOCH_SECONDS), + "a republish that lost the race must not re-export the epoch it read \ + before the newer sample completed" + ); + } + + #[test] + fn readiness_reason_labels_are_the_closed_lifecycle_set() { + assert_eq!( + [ReadinessReason::Ready, ReadinessReason::ShuttingDown].map(ReadinessReason::label), + READINESS_REASON_LABELS + ); } } diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 303ecff82f..3e96c02c13 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -25,7 +25,7 @@ use crate::connection::handle_connection; use crate::metrics::track_metrics; use crate::nip11::{nip11_document, relay_info_handler}; use crate::nip_fi_http::http_denial; -use crate::readiness::{self, ReadinessEvaluation, ReadinessReason}; +use crate::readiness::{self, DependencySnapshot, ReadinessReason}; use crate::state::AppState; // ── NIP-FI fail-closed assertion guard ─────────────────────────────────────── @@ -645,60 +645,48 @@ async fn liveness_handler() -> impl IntoResponse { (StatusCode::OK, "ok") } -/// Compatibility endpoint on the public listener. It evaluates dependencies -/// and preserves the existing response contract but never records rollout -/// telemetry. +/// Compatibility endpoint on the public listener. Same lifecycle answer as the +/// probe, but public traffic must never move rollout telemetry. async fn public_readiness_handler(State(state): State>) -> impl IntoResponse { - if !state.readiness.public_evaluation_allowed() { - return readiness_response(ReadinessEvaluation::shutting_down(), false); - } - - let evaluation = state.readiness.evaluate(&state.db, &state.redis_pool).await; - let evaluation = state.readiness.finish_public_evaluation(evaluation); - readiness_response(evaluation, false) + readiness_response(readiness_reason(&state)) } -/// Kubernetes health-listener endpoint. All rollout metrics flow through the -/// process-owned coordinator so shutdown and probe generations are ordered. +/// Kubernetes health-listener endpoint — the only source of rollout readiness +/// telemetry. Its single lifecycle sample determines every observable result +/// of this request: counter, gauge, HTTP status, and body. async fn kubernetes_readiness_handler(State(state): State>) -> impl IntoResponse { - let readiness::ProbeStart::Evaluate(ticket) = state.readiness.begin_probe() else { - return readiness_response(ReadinessEvaluation::shutting_down(), true); - }; - - let evaluation = state.readiness.evaluate(&state.db, &state.redis_pool).await; - let evaluation = state.readiness.finish_probe(ticket, evaluation); - readiness_response(evaluation, true) + let reason = readiness_reason(&state); + readiness::record_readiness_probe(reason); + readiness_response(reason) } -fn readiness_response( - evaluation: ReadinessEvaluation, - include_reason: bool, -) -> axum::response::Response { - if evaluation.reason == ReadinessReason::ShuttingDown { - return ( +/// Readiness answers for this process only. +/// +/// Shared Postgres, Redis, and deletion-catalog health used to gate this +/// answer, which meant one shared outage removed every replica from the load +/// balancer simultaneously and left a reconnect burst with nowhere to land. +/// Those checks now report on `/_status`. The health listener does not bind +/// until the database, migrations, Redis, and pub/sub are up (see +/// `buzz-relay/src/main.rs`), so an answering process is a booted process and +/// needs no separate startup state. +fn readiness_reason(state: &AppState) -> ReadinessReason { + if state.shutting_down.load(Ordering::Acquire) { + ReadinessReason::ShuttingDown + } else { + ReadinessReason::Ready + } +} + +fn readiness_response(reason: ReadinessReason) -> axum::response::Response { + match reason { + ReadinessReason::Ready => { + (StatusCode::OK, Json(json!({"status": "ready"}))).into_response() + } + ReadinessReason::ShuttingDown => ( StatusCode::SERVICE_UNAVAILABLE, Json(json!({"status": "shutting_down"})), ) - .into_response(); - } - - let pg_ok = evaluation.postgres_ready(); - let redis_ok = evaluation.redis_ready(); - let deletion_catalog_ok = evaluation.deletion_catalog_ready(); - - if evaluation.is_ready() { - (StatusCode::OK, Json(json!({"status": "ready"}))).into_response() - } else { - let mut payload = json!({ - "status": "not_ready", - "postgres": pg_ok, - "redis": redis_ok, - "deletion_catalog": deletion_catalog_ok - }); - if include_reason { - payload["reason"] = json!(evaluation.reason.label()); - } - (StatusCode::SERVICE_UNAVAILABLE, Json(payload)).into_response() + .into_response(), } } @@ -715,9 +703,44 @@ fn status_payload(uptime_secs: u64) -> serde_json::Value { }) } -/// Status endpoint — service name, version, uptime, and intrinsic build identity. +/// The dependency fields the readiness body used to carry, now a diagnostic +/// read of the sampler's cache. +/// +/// `sample` is always present so a reader can never mistake a cached verdict +/// for a current one: `not_yet_sampled` before the sampler's first evaluation +/// completes, then `fresh` or `stale` alongside the report's own age. +fn dependency_diagnostics_payload(snapshot: DependencySnapshot) -> serde_json::Value { + let interval_seconds = readiness::DEPENDENCY_SAMPLE_INTERVAL.as_secs(); + match snapshot { + DependencySnapshot::NotYetSampled => json!({ + "sample": "not_yet_sampled", + "sample_interval_seconds": interval_seconds, + }), + DependencySnapshot::Sampled { report, age, stale } => json!({ + "sample": if stale { "stale" } else { "fresh" }, + "sample_interval_seconds": interval_seconds, + "sample_age_seconds": age.as_secs(), + "postgres": report.postgres_ready(), + "redis": report.redis_ready(), + "deletion_catalog": report.deletion_catalog_ready(), + "reason": report.reason.label(), + }), + } +} + +/// Status endpoint — service name, version, uptime, intrinsic build identity, +/// and the cached shared-dependency diagnostics. +/// +/// Health-listener only, and never wired to a Kubernetes probe: this is where +/// an operator looks to tell "the pod is fine, Postgres is not" apart from "the +/// pod is broken". It reads only what +/// [`readiness::run_dependency_sampler`] has already cached, so however often +/// it is polled it adds no load to the shared pools. async fn status_handler(State(state): State>) -> impl IntoResponse { - Json(status_payload(state.started_at.elapsed().as_secs())) + let mut payload = status_payload(state.started_at.elapsed().as_secs()); + payload["dependencies"] = + dependency_diagnostics_payload(state.dependency_diagnostics.snapshot()); + Json(payload) } /// `/_mesh` — live mesh status: peer table, connection/phi state, per-peer @@ -761,7 +784,6 @@ fn build_cors_layer(cors_origins: &[String]) -> CorsLayer { #[cfg(test)] mod tests { use std::collections::VecDeque; - use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; use std::sync::{Mutex, PoisonError}; use std::time::Duration; @@ -770,91 +792,63 @@ mod tests { use opentelemetry::trace::TracerProvider as _; use opentelemetry_sdk::trace::{InMemorySpanExporter, SdkTracerProvider}; use tokio::net::TcpListener; - use tokio::sync::{mpsc, Notify}; + use tokio::sync::mpsc; use tokio_tungstenite::{connect_async, tungstenite::Message}; use tower::ServiceBuilder; use tracing::Instrument as _; use tracing_subscriber::prelude::*; use super::*; + use crate::readiness::DependencyReport; - struct ScriptedReadinessEvaluator { - evaluations: Mutex>, + struct ScriptedDependencyEvaluator { + evaluations: Mutex>, + evaluations_started: std::sync::atomic::AtomicUsize, } - impl ScriptedReadinessEvaluator { - fn new(evaluations: impl IntoIterator) -> Self { + impl ScriptedDependencyEvaluator { + fn new(evaluations: impl IntoIterator) -> Self { Self { evaluations: Mutex::new(evaluations.into_iter().collect()), + evaluations_started: std::sync::atomic::AtomicUsize::new(0), } } - fn push(&self, evaluation: ReadinessEvaluation) { + fn push(&self, evaluation: DependencyReport) { self.evaluations .lock() .unwrap_or_else(PoisonError::into_inner) .push_back(evaluation); } + + /// How many times a caller actually reached the shared dependencies. + fn evaluations_started(&self) -> usize { + self.evaluations_started.load(Ordering::SeqCst) + } } #[async_trait::async_trait] - impl readiness::ReadinessEvaluator for ScriptedReadinessEvaluator { + impl readiness::DependencyEvaluator for ScriptedDependencyEvaluator { async fn evaluate( &self, _db: &buzz_db::Db, _redis_pool: &deadpool_redis::Pool, - ) -> ReadinessEvaluation { + ) -> DependencyReport { + self.evaluations_started.fetch_add(1, Ordering::SeqCst); self.evaluations .lock() .unwrap_or_else(PoisonError::into_inner) .pop_front() - .expect("scripted readiness evaluation") + .expect("scripted dependency report") } } - struct BarrierReadinessEvaluator { - calls: AtomicUsize, - first_started: Notify, - release_first: Notify, - first: ReadinessEvaluation, - second: ReadinessEvaluation, - } - - impl BarrierReadinessEvaluator { - fn new(first: ReadinessEvaluation, second: ReadinessEvaluation) -> Self { - Self { - calls: AtomicUsize::new(0), - first_started: Notify::new(), - release_first: Notify::new(), - first, - second, - } - } - } - - #[async_trait::async_trait] - impl readiness::ReadinessEvaluator for BarrierReadinessEvaluator { - async fn evaluate( - &self, - _db: &buzz_db::Db, - _redis_pool: &deadpool_redis::Pool, - ) -> ReadinessEvaluation { - if self.calls.fetch_add(1, AtomicOrdering::SeqCst) == 0 { - self.first_started.notify_waiters(); - self.release_first.notified().await; - self.first - } else { - self.second - } - } - } - - fn readiness_evaluation( + fn dependency_report( postgres: readiness::PostgresOutcome, redis: readiness::RedisOutcome, deletion_catalog: readiness::DeletionCatalogOutcome, - ) -> ReadinessEvaluation { - ReadinessEvaluation::from_results( + ) -> DependencyReport { + DependencyReport::from_results( readiness::TimedOutcome::new(postgres, Duration::from_millis(35)), readiness::TimedOutcome::new(redis, Duration::from_millis(20)), readiness::TimedOutcome::new(deletion_catalog, Duration::from_millis(15)), @@ -862,8 +856,8 @@ mod tests { ) } - fn ready_evaluation() -> ReadinessEvaluation { - readiness_evaluation( + fn ready_report() -> DependencyReport { + dependency_report( readiness::PostgresOutcome::Success, readiness::RedisOutcome::Success, readiness::DeletionCatalogOutcome::Success, @@ -945,10 +939,19 @@ mod tests { Arc::new(state) } - async fn readiness_state(evaluator: Arc) -> Arc { + async fn readiness_state(evaluator: Arc) -> Arc { + let mut state = unreachable_dependency_state().await; + Arc::get_mut(&mut state) + .expect("sole reference") + .set_dependency_evaluator(evaluator); + state + } + + /// A relay process whose shared Postgres and Redis are both unroutable. + async fn unreachable_dependency_state() -> Arc { let mut config = crate::config::Config::from_env().expect("default config loads"); config.require_relay_membership = false; - config.database_url = "postgres://buzz:buzz_dev@127.0.0.1:1/buzz".to_string(); + config.database_url = "postgres://buzz:buzz_dev@127.0.0.1:1/buzz".to_string(); // sadscan:disable np.postgres.1 -- local test-only credentials on a closed port config.redis_url = "redis://127.0.0.1:1".to_string(); let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); let db = buzz_db::Db::from_pool(pool.clone()); @@ -968,7 +971,7 @@ mod tests { buzz_workflow::WorkflowConfig::default(), )); let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); - let (mut state, _audit_shutdown) = AppState::new( + let (state, _audit_shutdown) = AppState::new( config, db, redis_pool, @@ -980,7 +983,6 @@ mod tests { nostr::Keys::generate(), media_storage, ); - state.set_readiness_evaluator(evaluator); Arc::new(state) } @@ -1001,6 +1003,300 @@ mod tests { (status, payload) } + async fn status_request(router: Router) -> (StatusCode, serde_json::Value) { + let response = router + .oneshot( + Request::get("/_status") + .body(Body::empty()) + .expect("status request"), + ) + .await + .expect("status response"); + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), 64 * 1024) + .await + .expect("status response body"); + let payload = serde_json::from_slice(&body).expect("status JSON"); + (status, payload) + } + + /// The incident regression. Shared Postgres and Redis pressure took every + /// replica out of the load balancer at once, so a reconnect burst had + /// nowhere to land. Readiness answers for this process only: a pod whose + /// shared dependencies are unreachable is still a healthy pod, and only a + /// local shutdown may withdraw it. + #[tokio::test] + async fn readiness_answers_from_local_lifecycle_not_shared_dependencies() { + let state = unreachable_dependency_state().await; + + for router in [ + build_health_router(state.clone()), + build_router(state.clone()), + ] { + assert_eq!( + readiness_request(router).await, + (StatusCode::OK, json!({"status": "ready"})), + "unreachable shared dependencies must not deroute a healthy pod" + ); + } + + state.begin_shutdown(); + + for router in [ + build_health_router(state.clone()), + build_router(state.clone()), + ] { + assert_eq!( + readiness_request(router).await, + ( + StatusCode::SERVICE_UNAVAILABLE, + json!({"status": "shutting_down"}) + ), + "a draining pod must still withdraw itself" + ); + } + } + + /// Dependency health did not disappear with the probe — it moved to the + /// diagnostic endpoint, which is never wired to a Kubernetes probe. The + /// fields the readiness body used to carry are still there, now qualified + /// by how old the sample behind them is. + #[tokio::test] + async fn status_retains_dependency_diagnostics_off_the_probe_path() { + let evaluator = Arc::new(ScriptedDependencyEvaluator::new([dependency_report( + readiness::PostgresOutcome::Success, + readiness::RedisOutcome::PoolTimeout, + readiness::DeletionCatalogOutcome::Success, + )])); + let state = readiness_state(evaluator).await; + state + .dependency_diagnostics + .sample(&state.db, &state.redis_pool) + .await; + + let (status, payload) = status_request(build_health_router(state)).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(payload["service"], "buzz-relay"); + assert_eq!( + payload["dependencies"], + json!({ + "sample": "fresh", + "sample_interval_seconds": 30, + "sample_age_seconds": 0, + "postgres": true, + "redis": false, + "deletion_catalog": true, + "reason": "redis_pool_timeout" + }) + ); + } + + /// `/_status` is an operator diagnostic, not a dependency driver. Evaluating + /// per request let operator curiosity — and anything that polls the + /// endpoint — add Postgres, Redis, and deletion-catalog work to a shared + /// dependency that is already under pressure, with no bound on how many + /// evaluations could be in flight at once. The endpoint reads the cached + /// report the per-pod sampler owns and starts nothing. + #[tokio::test] + async fn status_reads_the_cached_report_and_never_starts_a_dependency_check() { + let evaluator = Arc::new(ScriptedDependencyEvaluator::new([ready_report()])); + let state = readiness_state(evaluator.clone()).await; + let health = build_health_router(state.clone()); + + for _ in 0..3 { + let (status, payload) = status_request(health.clone()).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + payload["dependencies"], + json!({ + "sample": "not_yet_sampled", + "sample_interval_seconds": 30, + }), + "before the first sample completes there is no report to serve" + ); + } + + assert_eq!( + evaluator.evaluations_started(), + 0, + "a status request must never reach the shared dependencies" + ); + + // Once the sampler has a report, and only then, the endpoint serves it. + state + .dependency_diagnostics + .sample(&state.db, &state.redis_pool) + .await; + let (status, payload) = status_request(health).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(payload["dependencies"]["sample"], json!("fresh")); + assert_eq!(payload["dependencies"]["reason"], json!("ready")); + assert_eq!( + evaluator.evaluations_started(), + 1, + "the sampler is the only caller that evaluates" + ); + } + + /// Always answers, recording how many evaluations started and the peak + /// number in flight, so a loop test can assert cadence and single-flight + /// without a scripted queue to exhaust. + struct ObservedDependencyEvaluator { + report: DependencyReport, + duration: Duration, + started: std::sync::atomic::AtomicUsize, + in_flight: std::sync::atomic::AtomicUsize, + peak_in_flight: std::sync::atomic::AtomicUsize, + } + + impl ObservedDependencyEvaluator { + fn new(report: DependencyReport, duration: Duration) -> Self { + Self { + report, + duration, + started: std::sync::atomic::AtomicUsize::new(0), + in_flight: std::sync::atomic::AtomicUsize::new(0), + peak_in_flight: std::sync::atomic::AtomicUsize::new(0), + } + } + + fn started(&self) -> usize { + self.started.load(Ordering::SeqCst) + } + + fn peak_in_flight(&self) -> usize { + self.peak_in_flight.load(Ordering::SeqCst) + } + } + + #[async_trait::async_trait] + impl readiness::DependencyEvaluator for ObservedDependencyEvaluator { + async fn evaluate( + &self, + _db: &buzz_db::Db, + _redis_pool: &deadpool_redis::Pool, + ) -> DependencyReport { + self.started.fetch_add(1, Ordering::SeqCst); + let in_flight = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1; + self.peak_in_flight.fetch_max(in_flight, Ordering::SeqCst); + if !self.duration.is_zero() { + tokio::time::sleep(self.duration).await; + } + self.in_flight.fetch_sub(1, Ordering::SeqCst); + self.report + } + } + + /// Runs the production sampler on a paused clock for `window`, then cancels + /// it and returns the evaluator's observations. + async fn run_sampler_for( + evaluator: Arc, + window: Duration, + ) -> Arc { + let state = readiness_state(evaluator.clone()).await; + let cancel = state.dependency_sampler_cancel.clone(); + let sampler = tokio::spawn(readiness::run_dependency_sampler( + state.clone(), + cancel.clone(), + )); + tokio::time::sleep(window).await; + cancel.cancel(); + sampler.await.expect("sampler task"); + evaluator + } + + /// Dependency telemetry must keep describing the shared dependencies whether + /// or not anyone reads `/_status`. Request-driven evaluation meant a quiet + /// endpoint produced a flat dashboard during the exact outage it existed to + /// explain. + #[test] + fn the_dependency_sampler_emits_telemetry_without_any_request() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .start_paused(true) + .build() + .expect("paused current-thread runtime"); + let (recorder, handle) = crate::metrics::readiness_test_recorder(); + + metrics::with_local_recorder(&recorder, || { + crate::metrics::describe_readiness_metrics(); + runtime.block_on(async { + // The first tick fires immediately, then one per cadence. + let evaluator = run_sampler_for( + Arc::new(ObservedDependencyEvaluator::new( + ready_report(), + Duration::ZERO, + )), + readiness::DEPENDENCY_SAMPLE_INTERVAL * 3 + Duration::from_secs(1), + ) + .await; + + assert_eq!(evaluator.started(), 4); + let rendered = handle.render(); + assert_eq!( + metric_value( + &rendered, + "buzz_readiness_dependency_checks_total{dependency=\"postgres\",outcome=\"success\"}" + ), + 4.0, + "every sampling cycle must publish its dependency outcomes" + ); + assert_eq!( + metric_value( + &rendered, + "buzz_readiness_check_duration_seconds_count{check=\"overall\"}" + ), + 4.0 + ); + assert!( + !rendered.contains("buzz_readiness_checks_total{"), + "sampling is not a readiness probe and must not move probe telemetry" + ); + }); + }); + } + + /// The bound that replaces the request-driven design's lack of one. The + /// sampler awaits each evaluation before taking the next tick, so a + /// dependency slower than the cadence lowers the sampling rate instead of + /// stacking probes on top of the slowness that caused it. + #[test] + fn the_dependency_sampler_never_runs_two_evaluations_at_once() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .start_paused(true) + .build() + .expect("paused current-thread runtime"); + + runtime.block_on(async { + let slow = readiness::DEPENDENCY_SAMPLE_INTERVAL * 2 + Duration::from_secs(1); + let window = readiness::DEPENDENCY_SAMPLE_INTERVAL * 10; + let evaluator = run_sampler_for( + Arc::new(ObservedDependencyEvaluator::new(ready_report(), slow)), + window, + ) + .await; + + assert_eq!( + evaluator.peak_in_flight(), + 1, + "the sampler must own the only in-flight evaluation" + ); + // 61-second evaluations run back to back from t=0 in a 300-second + // window: five, not the ten ticks the cadence offered. An + // evaluation started per tick regardless of the last one would + // have started ten and held several open at once. + assert_eq!(evaluator.started(), 5); + assert!( + evaluator.started() + < (window.as_secs() / readiness::DEPENDENCY_SAMPLE_INTERVAL.as_secs()) as usize, + "a slow dependency must throttle sampling, not be sampled on every tick" + ); + }); + } + fn readiness_metric_lines(rendered: &str) -> Vec<&str> { rendered .lines() @@ -1008,15 +1304,6 @@ mod tests { .collect() } - fn sorted_readiness_metric_lines(rendered: &str) -> Vec { - let mut lines = readiness_metric_lines(rendered) - .into_iter() - .map(str::to_owned) - .collect::>(); - lines.sort(); - lines - } - fn metric_value(rendered: &str, exact_prefix: &str) -> f64 { rendered .lines() @@ -1028,16 +1315,23 @@ mod tests { .unwrap_or_else(|| panic!("missing metric line: {exact_prefix}")) } + /// The frozen telemetry contract for the health listener. + /// + /// Readiness is lifecycle-only: its counter carries exactly two reasons and + /// its gauge is the latest private readiness-probe observation, never a + /// dependency or a transition-owned lifecycle mirror. Dependency families + /// are still exported, but only by the per-pod sampler, and neither + /// public-listener traffic nor an `/_status` request moves anything. #[test] - fn production_readiness_routes_export_the_frozen_health_only_contract() { + fn production_health_routes_export_the_frozen_telemetry_contract() { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("current-thread runtime"); - let evaluator = Arc::new(ScriptedReadinessEvaluator::new(std::iter::repeat_n( - ready_evaluation(), - 4, - ))); + // Seeded with the first sampling cycle only; the coverage loop below + // pushes the rest, one per cycle, so the evaluator never serves a + // report the assertions did not choose. + let evaluator = Arc::new(ScriptedDependencyEvaluator::new([ready_report()])); let (recorder, handle) = crate::metrics::readiness_test_recorder(); metrics::with_local_recorder(&recorder, || { @@ -1062,138 +1356,130 @@ mod tests { readiness_request(health.clone()).await, (StatusCode::OK, json!({"status": "ready"})) ); - let first_scrape = handle.render(); + let after_probe = handle.render(); - assert!(first_scrape.contains("# TYPE buzz_readiness_checks_total counter")); - assert!(first_scrape - .contains("# TYPE buzz_readiness_dependency_checks_total counter")); - assert!(first_scrape - .contains("# TYPE buzz_readiness_check_duration_seconds histogram")); - assert!(first_scrape.contains("# TYPE buzz_readiness_state gauge")); + assert!(after_probe.contains("# TYPE buzz_readiness_checks_total counter")); + assert!(after_probe.contains("# TYPE buzz_readiness_state gauge")); assert_eq!( - metric_value( - &first_scrape, - "buzz_readiness_checks_total{reason=\"ready\"}" - ), + metric_value(&after_probe, "buzz_readiness_checks_total{reason=\"ready\"}"), 1.0 ); + assert_eq!( + metric_value(&after_probe, "buzz_readiness_state{check=\"overall\"}"), + 1.0 + ); + assert!( + !after_probe.contains("buzz_readiness_dependency_checks_total{"), + "the probe must not touch a shared dependency" + ); + assert!( + !after_probe.contains("buzz_readiness_check_duration_seconds_count"), + "the probe must not record a dependency latency sample" + ); + + // Dependency telemetry now belongs to the sampler; the + // endpoint only reads what the sampler cached. + state + .dependency_diagnostics + .sample(&state.db, &state.redis_pool) + .await; + let (status, payload) = status_request(health.clone()).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(payload["dependencies"]["sample"], json!("fresh")); + assert_eq!(payload["dependencies"]["reason"], json!("ready")); + let after_status = handle.render(); + assert!( + after_status.contains("# TYPE buzz_readiness_dependency_checks_total counter") + ); + assert!( + after_status.contains("# TYPE buzz_readiness_check_duration_seconds histogram") + ); assert_eq!( metric_value( - &first_scrape, + &after_status, "buzz_readiness_dependency_checks_total{dependency=\"postgres\",outcome=\"success\"}" ), 1.0 ); - assert_eq!( - metric_value( - &first_scrape, - "buzz_readiness_state{check=\"overall\"}" - ), - 1.0 - ); for bucket in ["2", "2.5", "+Inf"] { - assert!(first_scrape.contains(&format!( + assert!(after_status.contains(&format!( "buzz_readiness_check_duration_seconds_bucket{{check=\"overall\",le=\"{bucket}\"}}" ))); } - assert!(!first_scrape.contains("result=")); - assert!(!first_scrape + assert!(!after_status.contains("result=")); + assert!(!after_status .lines() .filter(|line| line.starts_with("buzz_readiness_check_duration_seconds")) .any(|line| line.contains("outcome="))); + for dependency in ["postgres", "redis", "deletion_catalog"] { + assert!( + !after_status + .contains(&format!("buzz_readiness_state{{check=\"{dependency}\"}}")), + "dependency health has no publishable readiness gauge" + ); + } - let before_public_failure = sorted_readiness_metric_lines(&first_scrape); - evaluator.push(readiness_evaluation( - readiness::PostgresOutcome::Success, - readiness::RedisOutcome::PoolTimeout, - readiness::DeletionCatalogOutcome::Success, - )); - assert_eq!( - readiness_request(public.clone()).await, + // A failing dependency is reported and changes nothing about + // whether this pod stays in the load balancer. This set also + // covers every valid dependency/outcome pair, so the series + // total below is exact rather than merely bounded. + let coverage = [ ( - StatusCode::SERVICE_UNAVAILABLE, - json!({ - "status": "not_ready", - "postgres": true, - "redis": false, - "deletion_catalog": true - }) - ) - ); - assert_eq!( - sorted_readiness_metric_lines(&handle.render()), - before_public_failure - ); - - let contract_evaluations = [ - readiness_evaluation( readiness::PostgresOutcome::PoolTimeout, - readiness::RedisOutcome::Success, - readiness::DeletionCatalogOutcome::Success, + readiness::RedisOutcome::PoolTimeout, + readiness::DeletionCatalogOutcome::OperationTimeout, ), - readiness_evaluation( + ( readiness::PostgresOutcome::PoolError, - readiness::RedisOutcome::Success, - readiness::DeletionCatalogOutcome::Success, + readiness::RedisOutcome::PoolError, + readiness::DeletionCatalogOutcome::OperationError, ), - readiness_evaluation( + ( readiness::PostgresOutcome::QueryTimeout, readiness::RedisOutcome::Success, readiness::DeletionCatalogOutcome::Success, ), - readiness_evaluation( + ( readiness::PostgresOutcome::QueryError, readiness::RedisOutcome::Success, readiness::DeletionCatalogOutcome::Success, ), - readiness_evaluation( - readiness::PostgresOutcome::Success, - readiness::RedisOutcome::PoolTimeout, - readiness::DeletionCatalogOutcome::Success, - ), - readiness_evaluation( - readiness::PostgresOutcome::Success, - readiness::RedisOutcome::PoolError, - readiness::DeletionCatalogOutcome::Success, - ), - readiness_evaluation( - readiness::PostgresOutcome::Success, - readiness::RedisOutcome::Success, - readiness::DeletionCatalogOutcome::OperationTimeout, - ), - readiness_evaluation( - readiness::PostgresOutcome::Success, - readiness::RedisOutcome::Success, - readiness::DeletionCatalogOutcome::OperationError, - ), - readiness_evaluation( - readiness::PostgresOutcome::PoolTimeout, - readiness::RedisOutcome::PoolTimeout, - readiness::DeletionCatalogOutcome::OperationTimeout, - ), - readiness_evaluation( - readiness::PostgresOutcome::PoolError, - readiness::RedisOutcome::PoolError, - readiness::DeletionCatalogOutcome::Success, - ), ]; - for evaluation in contract_evaluations { - evaluator.push(evaluation); - let (status, payload) = readiness_request(health.clone()).await; - assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(payload["reason"], json!(evaluation.reason.label())); + for (index, (postgres, redis, deletion_catalog)) in + coverage.into_iter().enumerate() + { + evaluator.push(dependency_report(postgres, redis, deletion_catalog)); + state + .dependency_diagnostics + .sample(&state.db, &state.redis_pool) + .await; + let (status, degraded) = status_request(health.clone()).await; + assert_eq!(status, StatusCode::OK); + if index == 0 { + assert_eq!( + degraded["dependencies"], + json!({ + "sample": "fresh", + "sample_interval_seconds": 30, + "sample_age_seconds": 0, + "postgres": false, + "redis": false, + "deletion_catalog": false, + "reason": "overall_timeout" + }) + ); + } + assert_eq!( + readiness_request(health.clone()).await, + (StatusCode::OK, json!({"status": "ready"})), + "a failing dependency must never deroute this pod" + ); } - let before_shutdown = handle.render(); - let histogram_counts_before = ["overall", "postgres", "redis", "deletion_catalog"] - .map(|check| { - metric_value( - &before_shutdown, - &format!( - "buzz_readiness_check_duration_seconds_count{{check=\"{check}\"}}" - ), - ) - }); + let histogram_count_before = metric_value( + &handle.render(), + "buzz_readiness_check_duration_seconds_count{check=\"overall\"}", + ); state.begin_shutdown(); assert_eq!( readiness_request(public).await, @@ -1202,10 +1488,18 @@ mod tests { json!({"status": "shutting_down"}) ) ); - let after_public_shutdown = handle.render(); - assert!(after_public_shutdown + assert!(handle + .render() .lines() .all(|line| !line.contains("reason=\"shutting_down\""))); + assert_eq!( + metric_value( + &handle.render(), + "buzz_readiness_state{check=\"overall\"}" + ), + 1.0, + "shutdown and public traffic must not update the private probe gauge" + ); assert_eq!( readiness_request(health).await, @@ -1215,16 +1509,14 @@ mod tests { ) ); let final_scrape = handle.render(); - let histogram_counts_after = ["overall", "postgres", "redis", "deletion_catalog"] - .map(|check| { - metric_value( - &final_scrape, - &format!( - "buzz_readiness_check_duration_seconds_count{{check=\"{check}\"}}" - ), - ) - }); - assert_eq!(histogram_counts_after, histogram_counts_before); + assert_eq!( + metric_value( + &final_scrape, + "buzz_readiness_check_duration_seconds_count{check=\"overall\"}" + ), + histogram_count_before, + "shutdown must not fabricate a dependency latency sample" + ); assert_eq!( metric_value( &final_scrape, @@ -1233,13 +1525,27 @@ mod tests { 1.0 ); assert_eq!( - metric_value( - &final_scrape, - "buzz_readiness_state{check=\"overall\"}" - ), + metric_value(&final_scrape, "buzz_readiness_state{check=\"overall\"}"), 0.0 ); assert!(!final_scrape.contains("sensitive-sql-or-url")); + // Freshness is part of the frozen contract: one unlabelled + // gauge carrying when the cached report completed. The sampler + // advances it and the publisher re-emits it, so + // `time() - ` ages a stalled sampler out from a scrape + // alone. + assert!(final_scrape.contains( + "# TYPE buzz_readiness_dependency_sample_completed_timestamp_seconds gauge" + )); + assert_eq!( + final_scrape + .lines() + .filter(|line| line.starts_with( + "buzz_readiness_dependency_sample_completed_timestamp_seconds" + )) + .count(), + 1 + ); let exported_reasons = final_scrape .lines() @@ -1249,138 +1555,7 @@ mod tests { assert_eq!( readiness_metric_lines(&final_scrape).len(), readiness::READINESS_RAW_SERIES_PER_POD, - "readiness series contract must stay at or below its 99-series cap" - ); - }); - }); - } - - fn run_out_of_order_route_case( - first: ReadinessEvaluation, - second: ReadinessEvaluation, - ) -> (serde_json::Value, serde_json::Value, String) { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("current-thread runtime"); - let evaluator = Arc::new(BarrierReadinessEvaluator::new(first, second)); - let (recorder, handle) = crate::metrics::readiness_test_recorder(); - - metrics::with_local_recorder(&recorder, || { - runtime.block_on(async { - let state = readiness_state(evaluator.clone()).await; - let health = build_health_router(state); - let first_started = evaluator.first_started.notified(); - let slow_first = tokio::spawn(readiness_request(health.clone())); - first_started.await; - - let (_, second_payload) = readiness_request(health).await; - evaluator.release_first.notify_one(); - let (_, first_payload) = slow_first.await.expect("slow first probe task"); - (first_payload, second_payload, handle.render()) - }) - }) - } - - #[test] - fn real_health_route_generation_fence_covers_both_completion_orders() { - let failure = readiness_evaluation( - readiness::PostgresOutcome::Success, - readiness::RedisOutcome::PoolTimeout, - readiness::DeletionCatalogOutcome::Success, - ); - - let (older_failure, newer_success, success_scrape) = - run_out_of_order_route_case(failure, ready_evaluation()); - assert_eq!(older_failure["reason"], json!("redis_pool_timeout")); - assert_eq!(newer_success, json!({"status": "ready"})); - assert_eq!( - metric_value(&success_scrape, "buzz_readiness_state{check=\"overall\"}"), - 1.0 - ); - assert_eq!( - metric_value(&success_scrape, "buzz_readiness_state{check=\"redis\"}"), - 1.0 - ); - - let (older_success, newer_failure, failure_scrape) = - run_out_of_order_route_case(ready_evaluation(), failure); - assert_eq!(older_success, json!({"status": "ready"})); - assert_eq!(newer_failure["reason"], json!("redis_pool_timeout")); - assert_eq!( - metric_value(&failure_scrape, "buzz_readiness_state{check=\"overall\"}"), - 0.0 - ); - assert_eq!( - metric_value(&failure_scrape, "buzz_readiness_state{check=\"redis\"}"), - 0.0 - ); - for scrape in [&success_scrape, &failure_scrape] { - assert_eq!( - metric_value(scrape, "buzz_readiness_checks_total{reason=\"ready\"}"), - 1.0 - ); - assert_eq!( - metric_value( - scrape, - "buzz_readiness_checks_total{reason=\"redis_pool_timeout\"}" - ), - 1.0 - ); - } - } - - #[test] - fn real_health_route_shutdown_fence_dominates_an_in_flight_success() { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("current-thread runtime"); - let evaluator = Arc::new(BarrierReadinessEvaluator::new( - ready_evaluation(), - ready_evaluation(), - )); - let (recorder, handle) = crate::metrics::readiness_test_recorder(); - - metrics::with_local_recorder(&recorder, || { - runtime.block_on(async { - let state = readiness_state(evaluator.clone()).await; - let health = build_health_router(state.clone()); - let first_started = evaluator.first_started.notified(); - let in_flight = tokio::spawn(readiness_request(health)); - first_started.await; - - state.begin_shutdown(); - evaluator.release_first.notify_one(); - assert_eq!( - in_flight.await.expect("in-flight readiness task"), - ( - StatusCode::SERVICE_UNAVAILABLE, - json!({"status": "shutting_down"}) - ) - ); - - let scrape = handle.render(); - assert_eq!( - metric_value(&scrape, "buzz_readiness_state{check=\"overall\"}"), - 0.0 - ); - assert!(scrape - .lines() - .all(|line| !line.starts_with("buzz_readiness_state{check=\"postgres\"}"))); - assert_eq!( - metric_value( - &scrape, - "buzz_readiness_checks_total{reason=\"shutting_down\"}" - ), - 1.0 - ); - assert_eq!( - metric_value( - &scrape, - "buzz_readiness_dependency_checks_total{dependency=\"postgres\",outcome=\"success\"}" - ), - 1.0 + "readiness series contract must stay at or below its 87-series cap" ); }); }); diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 7237640200..0bcab5d63a 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -182,10 +182,99 @@ impl Drop for CommunityConnectionGuard { } } +/// Message reported when the one-time Redis bootstrap gate rejects startup. +/// +/// Bounded and stable so operators and the boot regression test can match on +/// it without parsing the underlying driver error. +pub const REDIS_BOOTSTRAP_FAILURE: &str = "Redis command path unavailable at startup"; + +/// Budget for the one-time bootstrap PING. A refused port answers immediately; +/// this only bounds a blackholed address, where hanging forever would be worse +/// than exiting. +const REDIS_BOOTSTRAP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + +/// Keep the Redis driver's per-command timeout outside the relay-owned startup +/// budget so [`REDIS_BOOTSTRAP_TIMEOUT`] remains the one authoritative bound. +const REDIS_BOOTSTRAP_DRIVER_TIMEOUT: std::time::Duration = + REDIS_BOOTSTRAP_TIMEOUT.saturating_mul(2); + +/// Proves once, during startup, that the Redis command path this pod will serve +/// from can actually be reached. +/// +/// `deadpool_redis` pools dial lazily and `PubSubManager::new` only allocates +/// channels, so without this nothing in boot ever opened a command connection: +/// a relay came up against a dead Redis, bound its health listener, and — since +/// readiness reports local lifecycle only — advertised ready forever. Binding +/// that listener is a one-way latch, so the check has to happen before it, and +/// it is deliberately a *startup* gate: once serving, a Redis blip is a +/// dependency failure and must never change readiness. +pub async fn verify_redis_command_path(pool: &deadpool_redis::Pool) -> anyhow::Result<()> { + let ping = async { + let connection = pool + .get() + .await + .map_err(|error| anyhow::anyhow!("{REDIS_BOOTSTRAP_FAILURE}: {error}"))?; + // This one-shot connection is removed from the pool so extending its + // driver timeout cannot leak into normal serving traffic. The relay's + // outer timeout below must bound both lazy checkout and PING. + let mut connection = deadpool_redis::Connection::take(connection); + connection.set_response_timeout(REDIS_BOOTSTRAP_DRIVER_TIMEOUT); + redis::cmd("PING") + .query_async::(&mut connection) + .await + .map_err(|error| anyhow::anyhow!("{REDIS_BOOTSTRAP_FAILURE}: {error}")) + }; + + match tokio::time::timeout(REDIS_BOOTSTRAP_TIMEOUT, ping).await { + Err(_) => Err(anyhow::anyhow!( + "{REDIS_BOOTSTRAP_FAILURE}: no response within {REDIS_BOOTSTRAP_TIMEOUT:?}" + )), + Ok(result) => result.map(|_| ()), + } +} + +/// Bounded outcome of the durable community-active check run when a socket is +/// admitted. +/// +/// `outcome` is the only dimension. Community, tenant, connection, and error +/// text are request-controlled and deliberately absent from the label set. +#[derive(Debug, Clone, Copy)] +enum AdmissionOutcome { + Active, + Inactive, + CheckError, +} + +impl AdmissionOutcome { + fn label(self) -> &'static str { + match self { + Self::Active => "active", + Self::Inactive => "inactive", + Self::CheckError => "check_error", + } + } +} + +fn record_admission_check(outcome: AdmissionOutcome) { + metrics::counter!( + "buzz_community_admission_checks_total", + "outcome" => outcome.label(), + ) + .increment(1); +} + /// Registers a socket, durably revalidates its community, then runs it. /// /// The ordering is the archival admission invariant: archive-before-query is /// observed by the query, while archive-after-registration sees the token. +/// +/// Admission is fail-closed: only an affirmative `Ok(true)` may serve. Both +/// `Ok(false)` and a lookup `Err` cancel, because neither proves this tenant is +/// currently admitted, and `docs/multi-tenant-relay.md` I5 +/// (`Inv_AdmissionFence`) grants capability only to an actor *currently* +/// admitted to that community. The two are still told apart in telemetry +/// (`buzz_community_admission_checks_total{outcome}`) so an operator can +/// separate archival from database pressure. pub(crate) async fn run_registered_community_connection( registry: &CommunityConnectionRegistry, connection_id: Uuid, @@ -201,9 +290,29 @@ pub(crate) async fn run_registered_community_connection record_admission_check(AdmissionOutcome::Active), + Ok(false) => { + record_admission_check(AdmissionOutcome::Inactive); + cancel.cancel(); + return; + } + Err(error) => { + // A lookup failure is not an answer, so it cannot authorize one. + // Admitting here would begin serving AUTH and REQ for a tenant + // whose lifecycle is unknown, and the adjacent host-binding seam + // already refuses on exactly this evidence (see + // `router::nip11_or_ws_handler`). The client sees an ordinary dial + // failure and retries. + record_admission_check(AdmissionOutcome::CheckError); + tracing::warn!( + %community_id, + %error, + "community active check failed; refusing the socket" + ); + cancel.cancel(); + return; + } } if cancel.is_cancelled() { return; @@ -719,8 +828,14 @@ pub struct AppState { pub audio_rooms: Arc, /// Set to `true` on SIGTERM — readiness probe returns 503. pub shutting_down: Arc, - /// Orders readiness gauge publication against terminal shutdown. - pub(crate) readiness: Arc, + /// Cached shared-dependency evaluation behind the diagnostic `/_status` + /// endpoint, owned by [`crate::readiness::run_dependency_sampler`]. Never + /// consulted by a Kubernetes probe, and never evaluated by a request. + pub(crate) dependency_diagnostics: Arc, + /// Stops only the periodic dependency sampler during graceful shutdown. + pub dependency_sampler_cancel: CancellationToken, + /// Stops only the completion-epoch publisher during graceful shutdown. + pub dependency_completion_publisher_cancel: CancellationToken, /// Process start time — used by `/_status` endpoint. pub started_at: Instant, /// Shared, community-scoped NIP-98 replay prevention. @@ -946,7 +1061,9 @@ impl AppState { git_pack_cache, audio_rooms: Arc::new(AudioRoomManager::new()), shutting_down: Arc::new(AtomicBool::new(false)), - readiness: Arc::new(crate::readiness::ReadinessCoordinator::default()), + dependency_diagnostics: Arc::new(crate::readiness::DependencyDiagnostics::default()), + dependency_sampler_cancel: CancellationToken::new(), + dependency_completion_publisher_cancel: CancellationToken::new(), started_at: Instant::now(), nip98_replay, gif_http_client, @@ -990,21 +1107,21 @@ impl AppState { ) } - /// Atomically closes readiness publication before exposing shutdown to - /// the relay's other fast-path lifecycle checks. + /// Withdraws this pod from routing. The lifecycle flag is authoritative for + /// `/_readiness`; the private probe publishes its sampled observation to + /// the readiness gauge on its next request. pub fn begin_shutdown(&self) { - self.readiness.begin_shutdown(); self.shutting_down.store(true, Ordering::Release); } #[cfg(test)] - pub(crate) fn set_readiness_evaluator( + pub(crate) fn set_dependency_evaluator( &mut self, - evaluator: Arc, + evaluator: Arc, ) { - self.readiness = Arc::new(crate::readiness::ReadinessCoordinator::with_evaluator( - evaluator, - )); + self.dependency_diagnostics = Arc::new( + crate::readiness::DependencyDiagnostics::with_evaluator(evaluator), + ); } /// Inter-relay mesh handle. `None` ⇒ mesh-off / single-instance: callers @@ -2096,6 +2213,144 @@ pub(crate) mod tests { assert!(!started_during.load(Ordering::SeqCst)); } + /// Reads one `buzz_community_admission_checks_total` series by exact label set. + fn admission_counter( + snapshot: &[( + metrics_util::CompositeKey, + Option, + Option, + metrics_util::debugging::DebugValue, + )], + outcome: &str, + ) -> Option { + snapshot.iter().find_map(|(key, _, _, value)| { + let labels = key + .key() + .labels() + .map(|label| (label.key(), label.value())) + .collect::>(); + if key.key().name() != "buzz_community_admission_checks_total" + || labels != [("outcome", outcome)] + { + return None; + } + match value { + metrics_util::debugging::DebugValue::Counter(count) => Some(*count), + _ => panic!("community admission checks must be a counter"), + } + }) + } + + /// Admission is fail-closed on both non-affirmative outcomes. A confirmed + /// `Ok(false)` and a lookup `Err` are different diagnoses — the counter + /// keeps them apart — but neither is proof of current admission, and + /// `docs/multi-tenant-relay.md` I5 (`Inv_AdmissionFence`) grants read or + /// membership capability only to an actor *currently* admitted to that + /// community. Serving AUTH/REQ on an unproven tenant lifecycle is the + /// failure this guards. + #[test] + fn neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current-thread runtime"); + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + + let (inactive_cancel, inactive_started, error_cancel, error_started, active_started) = + metrics::with_local_recorder(&recorder, || { + runtime.block_on(async { + let registry = CommunityConnectionRegistry::new(); + let community = CommunityId::from_uuid(Uuid::from_u128(0xa)); + + let inactive_cancel = CancellationToken::new(); + let inactive_started = Arc::new(AtomicBool::new(false)); + let started = Arc::clone(&inactive_started); + run_registered_community_connection( + ®istry, + Uuid::new_v4(), + community, + CommunityConnectionControl::new(inactive_cancel.clone()), + || async { Ok(false) }, + move |_| async move { started.store(true, Ordering::SeqCst) }, + ) + .await; + + let error_cancel = CancellationToken::new(); + let error_started = Arc::new(AtomicBool::new(false)); + let started = Arc::clone(&error_started); + run_registered_community_connection( + ®istry, + Uuid::new_v4(), + community, + CommunityConnectionControl::new(error_cancel.clone()), + || async { Err(buzz_db::DbError::Sqlx(sqlx::Error::PoolTimedOut)) }, + move |_| async move { started.store(true, Ordering::SeqCst) }, + ) + .await; + + let active_started = Arc::new(AtomicBool::new(false)); + let started = Arc::clone(&active_started); + run_registered_community_connection( + ®istry, + Uuid::new_v4(), + community, + CommunityConnectionControl::new(CancellationToken::new()), + || async { Ok(true) }, + move |_| async move { started.store(true, Ordering::SeqCst) }, + ) + .await; + + ( + inactive_cancel, + inactive_started, + error_cancel, + error_started, + active_started, + ) + }) + }); + + assert!( + inactive_cancel.is_cancelled(), + "a confirmed-inactive community must still cancel its socket" + ); + assert!( + !inactive_started.load(Ordering::SeqCst), + "a confirmed-inactive community must never start the socket body" + ); + assert!( + error_cancel.is_cancelled(), + "a failed active check must cancel its socket, not admit it" + ); + assert!( + !error_started.load(Ordering::SeqCst), + "a failed active check must never start serving AUTH/REQ on an unproven tenant" + ); + assert!(active_started.load(Ordering::SeqCst)); + + let snapshot = snapshotter.snapshot().into_vec(); + assert_eq!(admission_counter(&snapshot, "inactive"), Some(1)); + assert_eq!(admission_counter(&snapshot, "check_error"), Some(1)); + assert_eq!(admission_counter(&snapshot, "active"), Some(1)); + + let label_sets = snapshot + .iter() + .filter(|(key, _, _, _)| key.key().name() == "buzz_community_admission_checks_total") + .map(|(key, _, _, _)| { + key.key() + .labels() + .map(|label| label.key().to_owned()) + .collect::>() + }) + .collect::>(); + assert_eq!(label_sets.len(), 3, "outcome is the only dimension"); + assert!( + label_sets.iter().all(|labels| labels == &["outcome"]), + "admission telemetry must never carry community, tenant, or error labels: {label_sets:?}" + ); + } + #[tokio::test] async fn revalidation_continues_after_one_community_lookup_failure() { let registry = CommunityConnectionRegistry::new(); diff --git a/crates/buzz-relay/tests/boot_lifecycle.rs b/crates/buzz-relay/tests/boot_lifecycle.rs index 17762bbdea..1d43fc3236 100644 --- a/crates/buzz-relay/tests/boot_lifecycle.rs +++ b/crates/buzz-relay/tests/boot_lifecycle.rs @@ -3,6 +3,7 @@ use std::{ io::{Read as _, Write as _}, net::{TcpListener, TcpStream}, process::{Child, Command, ExitStatus, Output, Stdio}, + sync::mpsc, thread::{self, JoinHandle}, time::{Duration, Instant}, }; @@ -10,11 +11,14 @@ use std::{ use serde_json::Value; use buzz_relay::lifecycle::StartupPhase; +use buzz_relay::state::REDIS_BOOTSTRAP_FAILURE; const VALID_RELAY_PRIVATE_KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001"; const CHILD_TIMEOUT: Duration = Duration::from_secs(10); const MAX_CAPTURE_BYTES: u64 = 1024 * 1024; +/// Bound on every wait for the relay to export a metric family. +const METRICS_SCRAPE_DEADLINE: Duration = Duration::from_secs(8); struct RelayProcess { child: Option, @@ -156,20 +160,27 @@ fn scrape_metrics(port: u16) -> std::io::Result { } fn wait_for_relay_metrics(process: &mut RelayProcess, port: u16) -> String { - let deadline = Instant::now() + Duration::from_secs(8); + wait_for_scraped_metric(process, port, "buzz_audit_enabled") +} + +/// Polls the relay's own `/metrics` until `needle` appears, bounded by +/// [`METRICS_SCRAPE_DEADLINE`]. A relay that exits first is a failure, not a +/// timeout, so the panic names the real cause. +fn wait_for_scraped_metric(process: &mut RelayProcess, port: u16, needle: &str) -> String { + let deadline = Instant::now() + METRICS_SCRAPE_DEADLINE; loop { assert!( process.try_wait().is_none(), - "relay exited before its metrics endpoint became usable" + "relay exited before exporting {needle}" ); if let Ok(response) = scrape_metrics(port) { - if response.contains("buzz_audit_enabled") { + if response.contains(needle) { return response; } } assert!( Instant::now() < deadline, - "relay metrics did not become scrapeable within 8s" + "relay did not export {needle} within {METRICS_SCRAPE_DEADLINE:?}" ); thread::sleep(Duration::from_millis(20)); } @@ -500,3 +511,334 @@ fn successful_main_emits_complete_lifecycle_without_startup_metrics() { assert_terminal(&events, "metrics_bind", "succeeded", None); assert_terminal(&events, "process_telemetry", "succeeded", None); } + +/// Boot gates that need a live Postgres to reach the code under test. Named +/// `postgres_tests` so `.config/nextest.toml`'s `postgres-ci` default filter +/// discovers them structurally; the wrapper hands each test its own database +/// through `DATABASE_URL`. +mod postgres_tests { + use super::*; + + const REDIS_BOOTSTRAP_BUDGET: Duration = Duration::from_secs(5); + const REDIS_BOOTSTRAP_SCHEDULING_SLACK: Duration = Duration::from_secs(3); + + /// A TCP peer that completes Redis's metadata handshake, then reads and + /// holds PING without replying. This distinguishes a bounded checkout + + /// PING from a refused connection, which returns before the bootstrap + /// timeout is exercised. + struct HangingRedisPeer { + redis_url: String, + request_received: mpsc::Receiver>, + stop: mpsc::Sender<()>, + worker: Option>, + } + + impl HangingRedisPeer { + fn spawn() -> Self { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind fake Redis peer"); + listener + .set_nonblocking(true) + .expect("set fake Redis listener nonblocking"); + let port = listener.local_addr().expect("fake Redis address").port(); + let (request_tx, request_received) = mpsc::channel(); + let (stop, stop_rx) = mpsc::channel(); + let worker = thread::spawn(move || loop { + if stop_rx.try_recv().is_ok() { + return; + } + let (mut stream, _) = match listener.accept() { + Ok(accepted) => accepted, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + continue; + } + Err(error) => panic!("accept fake Redis connection: {error}"), + }; + stream + .set_read_timeout(Some(Duration::from_millis(100))) + .expect("bound fake Redis read"); + let mut request = Vec::new(); + loop { + let mut chunk = [0_u8; 4096]; + match stream.read(&mut chunk) { + Ok(0) => return, + Ok(read) => { + request.extend_from_slice(&chunk[..read]); + assert!( + request.len() <= 4096, + "fake Redis peer received an oversized request" + ); + let setinfo_commands = request + .windows(b"SETINFO".len()) + .filter(|window| *window == b"SETINFO") + .count(); + if setinfo_commands >= 2 { + // redis-rs pipelines CLIENT SETINFO lib-name + // and lib-ver while establishing a connection. + // Complete that handshake so the relay reaches + // its explicit bootstrap PING, then hold it. + stream + .write_all(b"+OK\r\n+OK\r\n") + .expect("reply to Redis client handshake"); + request.clear(); + continue; + } + if request + .windows(b"PING".len()) + .any(|window| window == b"PING") + { + let _ = request_tx.send(request); + let _ = stop_rx.recv_timeout(CHILD_TIMEOUT); + return; + } + } + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + if stop_rx.try_recv().is_ok() { + return; + } + } + Err(error) => panic!("read fake Redis request: {error}"), + } + } + }); + Self { + redis_url: format!("redis://127.0.0.1:{port}"), + request_received, + stop, + worker: Some(worker), + } + } + + fn redis_url(&self) -> &str { + &self.redis_url + } + + fn assert_request_received(&self, logs: &str) { + let request = self + .request_received + .recv_timeout(Duration::from_secs(1)) + .unwrap_or_else(|_| panic!("relay must reach the fake Redis peer: {logs}")); + assert!(!request.is_empty(), "fake Redis peer read an empty request"); + } + } + + impl Drop for HangingRedisPeer { + fn drop(&mut self) { + let _ = self.stop.send(()); + if let Some(worker) = self.worker.take() { + worker.join().expect("fake Redis peer must not panic"); + } + } + } + + fn reserve_closed_port() -> u16 { + let reserved = TcpListener::bind(("127.0.0.1", 0)).expect("reserve port"); + let port = reserved.local_addr().expect("reserved address").port(); + drop(reserved); + port + } + + /// Runs the relay until it exits on its own, or kills it once `timeout` + /// passes. Unlike `run_relay`, a relay that keeps serving is a result to + /// assert on rather than a panic, which is the whole point here. + fn run_until_exit(environment: &[(&str, &str)], timeout: Duration) -> (bool, Output) { + let mut process = RelayProcess::spawn(environment); + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if process.try_wait().is_some() { + return (true, process.wait(Duration::from_secs(2))); + } + thread::sleep(Duration::from_millis(20)); + } + (false, process.terminate()) + } + + /// Redis is required for pub/sub fan-out, presence, and typing, but nothing + /// in boot ever opened a command connection: `deadpool_redis` pools dial + /// lazily and `PubSubManager::new` only allocates channels, so "Redis + /// pub/sub connected" was logged against a dead port. A relay could + /// therefore boot with Redis unreachable, bind its health listener, and — + /// now that readiness answers from local lifecycle alone — advertise ready + /// for the rest of its life. The bootstrap gate is the one-time proof that + /// the command path has connected at least once, and it has to land before + /// the listener binds, because binding is the one-way latch that makes this + /// pod routable. + /// + /// The git conformance probe is disabled so the only remaining startup-fatal + /// gate is the one under test. + #[test] + #[ignore = "requires PostgreSQL"] + fn unreachable_redis_fails_boot_before_the_health_listener_binds() { + let database_url = std::env::var("DATABASE_URL") + .expect("postgres lane provides DATABASE_URL for each test process"); + let redis_url = format!("redis://127.0.0.1:{}", reserve_closed_port()); + let metrics_port = reserve_closed_port().to_string(); + let health_port = reserve_closed_port(); + let health_port_value = health_port.to_string(); + + let (exited, output) = run_until_exit( + &[ + ("BUZZ_RELAY_PRIVATE_KEY", VALID_RELAY_PRIVATE_KEY), + ("BUZZ_METRICS_PORT", &metrics_port), + ("BUZZ_HEALTH_PORT", &health_port_value), + ("DATABASE_URL", &database_url), + ("REDIS_URL", &redis_url), + ("BUZZ_GIT_CONFORMANCE_PROBE", "false"), + ], + CHILD_TIMEOUT, + ); + let logs = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + assert!( + !logs.contains("Health probe listener started"), + "the Redis bootstrap gate must run before the health listener binds: {logs}" + ); + assert!( + exited && !output.status.success(), + "an unreachable Redis command path must be startup-fatal: {logs}" + ); + assert!( + logs.contains(REDIS_BOOTSTRAP_FAILURE), + "the failure must name the gate that rejected boot: {logs}" + ); + assert!( + TcpListener::bind(("0.0.0.0", health_port)).is_ok(), + "the health port must never have been bound" + ); + } + + /// A peer that accepts the socket but withholds its Redis response exercises + /// the outer timeout around both lazy pool checkout and PING. Removing or + /// narrowing that timeout makes this test kill a still-running relay at the + /// deadline instead of observing a startup failure. + #[test] + #[ignore = "requires PostgreSQL"] + fn hanging_redis_peer_times_out_before_the_health_listener_binds() { + let database_url = std::env::var("DATABASE_URL") + .expect("postgres lane provides DATABASE_URL for each test process"); + let redis_peer = HangingRedisPeer::spawn(); + let metrics_port = reserve_closed_port().to_string(); + let health_port = reserve_closed_port(); + let health_port_value = health_port.to_string(); + let timeout = REDIS_BOOTSTRAP_BUDGET + REDIS_BOOTSTRAP_SCHEDULING_SLACK; + let started_at = Instant::now(); + + let (exited, output) = run_until_exit( + &[ + ("BUZZ_RELAY_PRIVATE_KEY", VALID_RELAY_PRIVATE_KEY), + ("BUZZ_METRICS_PORT", &metrics_port), + ("BUZZ_HEALTH_PORT", &health_port_value), + ("DATABASE_URL", &database_url), + ("REDIS_URL", redis_peer.redis_url()), + ("BUZZ_GIT_CONFORMANCE_PROBE", "false"), + ], + timeout, + ); + let elapsed = started_at.elapsed(); + let logs = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + redis_peer.assert_request_received(&logs); + + assert!( + elapsed >= REDIS_BOOTSTRAP_BUDGET, + "the fake peer must hold the request through the bootstrap budget: {elapsed:?}: {logs}" + ); + assert!( + exited && elapsed < timeout && !output.status.success(), + "the outer bootstrap timeout must terminate the relay within scheduling slack: {elapsed:?}: {logs}" + ); + assert!( + logs.contains(REDIS_BOOTSTRAP_FAILURE), + "the timeout must report the bounded Redis bootstrap failure: {logs}" + ); + assert!( + !logs.contains("Health probe listener started"), + "the health listener must not bind before Redis bootstrap succeeds: {logs}" + ); + assert!( + TcpListener::bind(("0.0.0.0", health_port)).is_ok(), + "the health port must never have been bound" + ); + } + + /// Startup is the only owner of dependency evaluation. `/_status` just reads + /// the cache, a readiness probe records no dependency attempt at all, and no + /// request path may start a check — so if `main` stops spawning the sampler, + /// this pod evaluates Postgres, Redis, and the deletion catalog exactly + /// never: the dependency families stay absent from its scrape and `/_status` + /// answers `not_yet_sampled` for the pod's whole life. + /// + /// No in-process test can fail on that, because each one drives `sample` + /// itself. This one boots the real binary against real dependencies and + /// reads only the relay's own `/metrics` — no probe, no `/_status`, nothing + /// that could evaluate a dependency on the test's behalf. The first tick + /// fires immediately, so the wait is bounded by + /// [`METRICS_SCRAPE_DEADLINE`] and never a fixed sleep. + /// + /// The completion-timestamp gauge is the needle because `sample` writes it + /// last, after the cache already serves the report: observing it proves the + /// whole cycle ran, with no window where the counters have landed but the + /// timestamp has not. + #[test] + #[ignore = "requires PostgreSQL"] + fn startup_owns_the_dependency_sampler() { + let database_url = std::env::var("DATABASE_URL") + .expect("postgres lane provides DATABASE_URL for each test process"); + let redis_url = std::env::var("REDIS_URL") + .expect("the postgres lane runs alongside Redis and exports REDIS_URL"); + let metrics_port = reserve_closed_port(); + let metrics_port_value = metrics_port.to_string(); + let health_port_value = reserve_closed_port().to_string(); + let bind_addr = format!("127.0.0.1:{}", reserve_closed_port()); + + let mut process = RelayProcess::spawn(&[ + ("BUZZ_RELAY_PRIVATE_KEY", VALID_RELAY_PRIVATE_KEY), + ("BUZZ_METRICS_PORT", &metrics_port_value), + ("BUZZ_HEALTH_PORT", &health_port_value), + ("BUZZ_BIND_ADDR", &bind_addr), + ("DATABASE_URL", &database_url), + ("REDIS_URL", &redis_url), + ("BUZZ_GIT_CONFORMANCE_PROBE", "false"), + ]); + let scrape = wait_for_scraped_metric( + &mut process, + metrics_port, + "buzz_readiness_dependency_sample_completed_timestamp_seconds", + ); + let output = process.terminate(); + let logs = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + assert!( + scrape.contains("buzz_readiness_dependency_checks_total{"), + "the same cycle must publish its per-dependency outcomes: {scrape}" + ); + assert!( + scrape.contains("buzz_readiness_check_duration_seconds"), + "a completed evaluation must publish its latency too: {scrape}" + ); + assert!( + !scrape.contains("buzz_readiness_checks_total{"), + "no readiness probe was sent, so the sampler alone produced this: {scrape}" + ); + assert!( + logs.contains("Health probe listener started"), + "the sampler must be owned by a relay that finished booting: {logs}" + ); + } +} diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index a6b74e8757..f5075fd19d 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -110,10 +110,11 @@ SigV4 signing, so do not put the bucket into `s3.endpoint`; pass Railway's base Object storage is contacted during relay startup only when `BUZZ_GIT_CONFORMANCE_PROBE` is enabled (the relay default). A probe failure is -startup-fatal, so Kubernetes readiness never opens. If an operator explicitly -disables that probe through `relay.extraEnv`, `/_readiness` does not test object -storage; configuration is still parsed strictly, but reachability and addressing -errors surface on the first storage operation. +startup-fatal, so the process exits and Kubernetes readiness never opens. If an +operator explicitly disables that probe through `relay.extraEnv`, configuration +is still parsed strictly, but reachability and addressing errors surface on the +first storage operation. `/_readiness` tests no external dependency in either +case — see the readiness contract below. ### Early-startup telemetry contract @@ -125,26 +126,114 @@ These phases intentionally do not emit metrics. Most run before the Prometheus exporter exists, and one uniform log-only contract preserves every phase's real event time and failure without assigning an eventual scrape time to earlier work. +### Readiness contract + +**`/_readiness` reports local process lifecycle only.** It performs no +Postgres, Redis, or deletion-catalog I/O: `shutting_down` returns 503, and any +other state returns 200. Shared dependencies are shared by every replica, so +gating the probe on them removed the whole deployment from the load balancer at +once and left a reconnect burst with nowhere to land. There is no separate +"starting" state — the health listener does not bind until the database, +migrations, Redis, and pub/sub are up, so a process that can answer has booted. + +Shared-dependency health moved to **`/_status`** on the same private health +listener, under a `dependencies` object carrying the `postgres`, `redis`, +`deletion_catalog`, and aggregate `reason` fields the readiness body used to +return. Do not wire `/_status` to a Kubernetes probe. + +**`/_status` is a cached read.** It performs no Postgres, Redis, or +deletion-catalog I/O of its own. One background loop per pod evaluates the +three dependencies **every 30 seconds**, awaiting each evaluation before taking +the next tick, so a pod never has more than one evaluation in flight no matter +how often — or how rarely — the endpoint is read. The loop publishes the +dependency metrics below and caches the report `/_status` serves. Its first +cycle runs at startup, and each evaluation is bounded by a two-second budget. + +Every `dependencies` object therefore states how old its report is: + +| Field | Meaning | +|-------|---------| +| `sample: "not_yet_sampled"` | the first cycle has not completed; no `postgres`/`redis`/`deletion_catalog`/`reason` fields are present, because there is no observation to report | +| `sample: "fresh"` | the report is at most two cadences (60s) old | +| `sample: "stale"` | the report outlived two cadences, so the sampler missed at least one cycle — read the verdict as history, not as current state | +| `sample_age_seconds` | age of the report at request time (absent when `not_yet_sampled`) | +| `sample_interval_seconds` | the sampling cadence, `30` | + +Polling `/_status` more often than the cadence returns the same cached report; +it does not make the data fresher and adds no dependency load. + ### Readiness telemetry contract Only requests served by the private health listener (`BUZZ_HEALTH_PORT`) emit -rollout readiness telemetry. The compatibility `/_readiness` route on the public -app listener returns health but does not change these metrics. +rollout telemetry. The compatibility `/_readiness` route on the public app +listener returns the same lifecycle answer but does not change these metrics. + +| Metric | Type | Labels | Source | +|--------|------|--------|--------| +| `buzz_readiness_checks_total` | counter | `reason` ∈ {`ready`, `shutting_down`} | `/_readiness` | +| `buzz_readiness_state` | gauge | `check="overall"`; latest private probe observation, 1 ready or 0 shutting down | `/_readiness` | +| `buzz_readiness_dependency_checks_total` | counter | `dependency`, typed bounded `outcome` | dependency sampler | +| `buzz_readiness_check_duration_seconds` | histogram | `check` only | dependency sampler | +| `buzz_readiness_dependency_sample_completed_timestamp_seconds` | gauge | none; Unix time the cached report completed | completion publisher | + +The three dependency families keep their `buzz_readiness_*` names for dashboard +continuity, but nothing about them is request-driven any more: the 30-second +sampler publishes them whether or not anyone reads `/_status`, so a quiet +endpoint no longer produces a flat dashboard during the outage it exists to +explain. + +`buzz_readiness_dependency_sample_completed_timestamp_seconds` carries **when +the cached report completed**, in Unix seconds. The relay sampler is the only +owner allowed to advance that epoch, and it writes it immediately after the +cache is replaced. A separate bounded publisher re-emits the stored epoch often +enough to survive local gauge idle-timeout; republishing never advances the +timestamp. If a republish races a newer completion, one scrape can briefly see +the older epoch, but the republish path verifies after writing and repairs to the +newer stored epoch before that republish call returns. So the value stands still +when sampling stops, and the time elapsed since it was written is whatever the +reader computes at read time. + +Following the `buzz_storage_sweep_age_seconds` convention, the series is not +emitted until the first sample completes: its absence means "not yet sampled", +not "fresh". + +That is the whole server-side contract. Freshness alerting is built from this +gauge in the monitoring provider, and the monitor query, thresholds, and +per-pod tag grouping belong with the deployment's monitor configuration rather +than in this chart — they depend on the provider's query grammar and on the +tags its agent attaches, neither of which this repo owns. + +Two properties of the neighboring families are worth knowing when building +that alerting. `buzz_readiness_dependency_checks_total` and +`buzz_readiness_check_duration_seconds` are cumulative, so a sampler that stops +leaves their last values exported and scraped indefinitely: those series stay +present and flat rather than disappearing. And per-report freshness for a human +reading a single pod is already on the `sample`, `sample_age_seconds`, and +`sample_interval_seconds` fields of `/_status` above. + +The schema has a ceiling of 87 raw Prometheus series per pod: 2 probe reasons, +11 valid dependency/outcome pairs, 72 histogram series, and 2 gauges (overall lifecycle + completion timestamp). Do not +add pod, ReplicaSet, version, rollout, error text, SQL, URL, tenant, user, +community, pubkey, header, query, or other request-controlled labels. A +readiness probe records no dependency attempt or latency sample at all. +The readiness gauge is not a monotonic lifecycle mirror: shutdown changes the +authoritative lifecycle flag, and the next private readiness probe observes and +publishes that state. + +### Community admission telemetry | Metric | Type | Labels | |--------|------|--------| -| `buzz_readiness_checks_total` | counter | `reason` from the closed readiness-reason set | -| `buzz_readiness_dependency_checks_total` | counter | `dependency`, typed bounded `outcome` | -| `buzz_readiness_check_duration_seconds` | histogram | `check` only | -| `buzz_readiness_state` | gauge | `check` only; latest publishable generation | +| `buzz_community_admission_checks_total` | counter | `outcome` ∈ {`active`, `inactive`, `check_error`} | -The schema has a ceiling of 99 raw Prometheus series per pod: 12 overall -reasons, 11 valid dependency/outcome pairs, 72 histogram series, and 4 gauges. -Do not add pod, ReplicaSet, version, rollout, error text, SQL, URL, tenant, -user, community, pubkey, header, query, or other request-controlled labels. -Shutdown without dependency evaluation increments only -`buzz_readiness_checks_total{reason="shutting_down"}` and sets the overall -state to zero; it does not fabricate dependency failures or latency samples. +Counts the durable community-active check run before a socket is admitted. +Admission is fail-closed: only `active` serves. `inactive` (a confirmed +archival answer) and `check_error` (the lookup itself failed, so the tenant +lifecycle is unknown) both refuse the socket before any AUTH or REQ frame is +read; the client sees an ordinary dial failure and retries. The two outcomes +stay distinct so a rise in `check_error` reads as database pressure rather than +archival. `outcome` is the only dimension — community and error text are +request-controlled and must never become labels. ### Operation-aware database pool acquisition contract diff --git a/docs/deployment-identity.md b/docs/deployment-identity.md index 32d8c426e7..f5bc8710d1 100644 --- a/docs/deployment-identity.md +++ b/docs/deployment-identity.md @@ -47,6 +47,15 @@ The relay health listener exposes intrinsic build identity at `/_status`: "source_sha": "<40-character-source-sha>", "id": "github-actions::", "url": "https://github.com/block/buzz/actions/runs//attempts/" + }, + "dependencies": { + "sample": "fresh", + "sample_interval_seconds": 30, + "sample_age_seconds": 12, + "postgres": true, + "redis": true, + "deletion_catalog": true, + "reason": "ready" } } ``` @@ -54,6 +63,16 @@ The relay health listener exposes intrinsic build identity at `/_status`: Non-CI builds report stable `unknown` or `local` fallback values instead of claiming provenance they do not have. +`dependencies` is a cached diagnostic snapshot of shared-dependency health. A +per-pod background loop evaluates the dependencies every 30 seconds; the +endpoint only reads the latest report and never contacts a dependency itself, +so polling it costs nothing. `sample` is always present and reports whether +that cached verdict is `fresh`, `stale`, or `not_yet_sampled` — before the +first cycle completes the health fields are absent rather than defaulted. +`/_readiness` does not consult any of this — see +[the readiness contract](../deploy/charts/buzz/README.md#readiness-contract) — +so this endpoint must never be wired to a Kubernetes probe. + ## Helm digest pinning Buzz chart `0.1.8` and newer accept an immutable image digest: diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 3732cdc476..32bfa1c94b 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -155,9 +155,9 @@ run_unit_tests() { run_test_step "buzz-acp unit tests" \ cargo test -p buzz-acp --lib -- --nocapture - # Mirror the three infra-free relay handler modules in `just test-unit`'s - # nextest expression. Keep the side-effects filter pinned to `::tests::` so - # it does not select the sibling Postgres-backed test module. + # Mirror the relay filters from `just test-unit`: the three handler modules, + # storage-snapshot helpers, readiness and router unit suites, and the single + # scoped admission regression in state::tests. run_test_step "buzz-relay channel authorization tests" \ cargo test -p buzz-relay --lib handlers::channel_authz:: -- --nocapture @@ -169,6 +169,15 @@ run_unit_tests() { run_test_step "buzz-relay storage snapshot tests" \ cargo test -p buzz-relay --lib storage_sweep::tests:: -- --nocapture + + run_test_step "buzz-relay readiness tests" \ + cargo test -p buzz-relay --lib readiness::tests:: -- --nocapture + + run_test_step "buzz-relay router tests" \ + cargo test -p buzz-relay --lib router::tests:: -- --nocapture + + run_test_step "buzz-relay admission regression test" \ + cargo test -p buzz-relay --lib state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket -- --nocapture } # ---- DB / integration tests (infra required) -------------------------------- From f7ff4294fa95191d1fc7b29a765e6f0b48db6bd8 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 28 Sep 2026 14:46:56 -0400 Subject: [PATCH 06/12] feat(nip-fi): harden Blossom kind-24242 verifier to NIP-FI spec (#7288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings `buzz-media`, `buzz-relay`, all first-party kind-24242 minters, and desktop token minting into compliance with [NIP-FI §kind-24242](https://github.com/block/buzz/blob/main/docs/nips/NIP-FI.md). ## What changed **`buzz-media/src/auth.rs`** - Add `BlossomStrictness` enum (`Strict` | `Permissive`). `Strict` applies full NIP-FI rules (active modes); `Permissive` preserves pre-NIP-FI Off-mode behavior byte-identical [FI-INV-15]. - Rewrite `verify_blossom_auth_event_for_verb` with count-based cardinality: exactly one `t`/`expiration`/`server` in `Strict`; duplicate `x` also rejected. `Permissive` keeps main's boolean semantics exactly: any valued `t` other than the verb (including `""`) rejects, valueless `t` is ignored. - `Strict`: mandatory `server` tag on all proofs (upload + read); absent or mismatched → `evidence_rejected` (`ServerMismatch`). - `Strict`: 60-second proof window (`now - created_at <= 60s`, `expiration <= created_at + 60s`). `Permissive`: 3600s / optional server. - **[F1]** Permissive server validation uses any-match semantics: a proof with multiple `server` tags is accepted if any tag matches the bound host. This restores origin/main base behavior. Strict duplicate-rejection unchanged. - **[F1 compat]** Permissive server gate uses `server_values.is_empty()` (valued tags only) rather than `server_count > 0` (includes valueless tags). A lone valueless `["server"]` tag is treated as absent in Permissive/Off mode, matching pre-NIP-FI base behavior [FI-INV-15]. - **[F4]** Valueless `x` tags in Strict get reads are tracked before `filter_map` so they cannot become absent scope (host-wide authorization). Any `x` tag present in Strict must contain the exact requested sha256. - **[F3 unit]** Cases A–D in `auth.rs` are helper-level unit tests: they call `verify_blossom_upload_auth` and `verify_upload_hash_only` directly. They do not enter the upload pipeline (`process_upload`/`process_video_upload`). Retained as helper evidence. **`buzz-media/src/error.rs`** - Add `DuplicateTag(&'static str)` variant. - Add `blossom_denial_kind()` method: classifies each auth error as `MissingEvidence`, `EvidenceRejected`, or `AuthorizationDenied`. Non-auth errors return `None`. - `IntoResponse` retains the legacy JSON 401 shape [FI-INV-15]. **`buzz-relay/src/api/media.rs`** - Add `MediaDenial(MediaError, BlossomStrictness)` wrapper mapping to byte-exact NIP-FI fixed responses in `Strict`; falls through to legacy JSON in `Permissive`. - `blossom_strictness_from_state()` selects `Strict` when `state.config.nip_fi.is_enforce()`, otherwise `Permissive`. Strictness is threaded through the `admit_nip_fi_http_on_state` NIP-98 closures on upload and read, and through the post-admission X-SHA-256, hash-binding, and membership denials. - `extract_blossom_auth` keeps first-value `Authorization` extraction; Enforce rejects repeated fields at the `admit_nip_fi_http` cardinality gate, and Off keeps legacy behavior [FI-INV-15]. - Both membership-denial call sites use `media_denial(RelayMembershipRequired, strictness)` so Strict mode produces the correct fixed-text response. - `upload_blob` serving-write fence conversion (`:406`) uses `map_err(|e| media_denial(e, strictness))` — a community fence committed between auth admission and lease acquisition no longer resets Strict to Permissive. - Mode-driven regressions in `mod postgres_tests` (selected by the nextest `postgres-ci` profile via `test(/postgres_tests::/)`, `#[ignore = "requires Postgres"]`), run through the full relay router with a real Enforce verifier and assertion: Enforce rejects Permissive-only proofs (300s lifetime, missing `server` tag) on both upload routes and reads with `evidence rejected`; Off accepts the same proofs with pre-NIP-FI responses; DenyProtected answers 503 before any proof check; membership denials are `authorization denied` text/plain in Enforce and exact legacy JSON 403 in Off, with a member positive control. GET and HEAD are each discriminated (HEAD with a suppressed body). Both upload routes pin the handler's `X-SHA-256` missing/malformed/unsigned and post-body hash-mismatch denials in both modes. Off rejects an empty-valued `t` beside a valid one with legacy 401 JSON in either order, and ignores a valueless one. Exact status, Content-Type, challenge, and body bytes are pinned. **`buzz-media/src/upload.rs`** (`mod minio_tests`) - **[F3 pipeline]** Four tests exercising the actual post-body call sites (`process_upload` `:85`, `process_video_upload` `:413`) with real MinIO I/O: buffered/streaming × expired-proof-accept + mismatched-hash-reject. The positive cases use `fresh_strict_upload_auth` (`created_at = now-58`, `expiration = now+2`, lifetime = 60s) — Strict-admissible at sign time — followed by a real 3s sleep so the proof is expired when the upload completes. `nostr::Timestamp::now()` reads the OS wall clock directly; paused Tokio time does not advance it. The negative cases use a pre-expired proof with a mismatched `x` tag. All four are `#[ignore = "requires MinIO"]`; no CI lane runs them automatically. **`buzz-media/src/validation.rs`** - `minimal_valid_mp4()` promoted to `#[cfg(test)] pub(crate)` — a 618-byte pre-computed H.264/avc1 fast-start MP4 available to all `buzz-media` test modules; smoke-tested by `test_minimal_valid_mp4_passes_validate_video_file`. **`desktop/src-tauri/src/commands/media.rs`** - `sign_blossom_upload_auth`: `server` tag mandatory; upload expiry 60s (was 3600s/300s). - `MEDIA_GET_AUTH_EXPIRY_SECS`: 60s (was 600s). **`desktop/src-tauri/src/media_proxy.rs`** - Single re-mint+retry on 401/403 for range requests; non-auth failures do not retry. **`crates/buzz-cli/src/client.rs`** - Read expiry: 60s (was 600s). Upload expiry: 60s. `server` tag mandatory on upload. **`crates/buzz-dev-mcp/src/view_image.rs`** - `MEDIA_GET_AUTH_EXPIRY_SECS`: 60s (was 600s). Test updated. **`mobile/lib/shared/relay/media_auth.dart`** - `_mediaGetAuthLifetimeSeconds`: 60 (was 600). Refresh margin updated. **`mobile/lib/shared/relay/media_upload.dart`** - `_uploadAuthLifetimeSeconds`: 60 (was 300). `server` tag mandatory. **`mobile/lib/features/channels/media_viewer_page/video_viewer.dart`** - **[F2]** Remove Android-specific `VideoPlayerController.networkUrl` streaming path. `video_player_android` 2.9.5 freezes headers into static `DefaultHttpDataSource` request properties — a 60s proof minted at controller creation time becomes stale on seeks. All platforms use the authenticated local-file download path. - **[F2 transport]** `unawaited(request.sink.close())` before `client.send()`. `AbortableStreamedRequest` carries no request body; without closing the sink, `IOClient.send()` awaits `stream.pipe(ioRequest)` which blocks until the sink ends — every video download hung in loading indefinitely on every platform. - **[F2 pending-ctrl]** `pendingController` ref set before the first `await (initialize())`, cleared in all exit paths. Effect cleanup disposes `pendingController.value` so a close-during-init releases the native player even if the initialized event never arrives. - **[F2r(a)]** On native init failure (post-create, pre-initialized-event), the inner catch starts disposal detached via `unawaited(localController.dispose().catchError(...))` — the `.catchError` handler absorbs any disposal exception so it does not reach the test error zone — then rethrows the original load error. `video_player` 2.11.1 completes the init future with an error without disposing the native player. - **[F2r(b)]** Non-2xx error body cancelled via `_cancelVideoResponse()` (subscribe+cancel) instead of `drain()`. `drain()` waits for the upstream to close; a stalled server body blocked `initializeVideo` indefinitely. - **[F2r(d)]** `unawaited(localController.dispose().catchError(...))` in the catch — not `await`. Two failure mechanisms exist: (1) when `createWithOptions()` throws, `_creatingCompleter` is never completed and `await dispose()` deadlocks at `:682-683`, blocking the outer catch from setting error state. (2) when `createWithOptions()` succeeds but initialization fails, `dispose()` runs but its thrown exception becomes an unhandled async error reaching the test zone. Both are fixed: `unawaited` lets the outer catch run immediately and set `error.value`; `.catchError(...)` on the detached disposal future absorbs any disposal exception. **`mobile/test/features/channels/media_viewer_page/video_viewer_test.dart`** - `_FinalizingFakeClient`: calls `request.finalize().drain()` — a test without the sink fix times out because the drain never completes, validating the transport contract. - F2r(a): `forceInitError=true` fake emits `PlatformException` on subscribe; `disposeCallCount >= 1` verifies disposal even on init failure. - F2r(b): `stalledBody` `StreamController` with `onCancel` Completer (bounded completion signal, not a sleep). Asserts: (1) `bodyStreamCancelled=true` while body stream is still open (impossible with `drain()`); (2) error UI visible while body remains open. Restoring `drain()` breaks both. - F2r(c): `neverInitialize=true` fake never sends initialized event; unmount asserts `disposeCallCount >= 1` via `pendingController`. Distinct from the event-error case. - F2r(d): `forceCreateError=true` fake throws `PlatformException` from `createWithOptions()` itself (before any player ID is returned). Asserts error UI visible within a bounded deadline. With the old `await dispose()` the outer catch is blocked — `error.value` is never set and the error UI assertion fails at the deadline; with `unawaited(dispose())` the error text appears immediately. **`mobile/test/features/channels/media_viewer_page/video_viewer_transport_test.dart`** (separate file) - Real-IO loopback probes using `flutter_test` (not `package:test`) to avoid `depend_on_referenced_packages` lint. No `TestWidgetsFlutterBinding` — no HttpClient override interfering with `dart:io`. - Probe 1: `HttpServer.bind(loopbackIPv4, 0)` reads the full request body before replying. Without `sink.close()`, `req.drain()` hangs and the test times out. - Probe 2: server signals request arrival via a Completer (not a sleep); abort fires after confirmed arrival; `client.send()` must throw `RequestAbortedException` (typed assertion) within a bounded 5s deadline. **`scripts/test-video-upload.sh`** - Proof minted with `+60` expiry and mandatory `server` tag. **`buzz-test-client/tests/e2e_media*.rs`** + **`buzz-relay/src/api/media.rs` test fixtures** - All fixtures updated to 55s expiry + `server` tag. **`RELEASING.md`** - New "NIP-FI upload proof freshness (relay floor)" section documenting that 60s proofs require the first relay release carrying this PR's post-body hash-only check (the first tag > `v0.2.1` — update once cut) for reliable large-file uploads. Operators on older self-hosted relays (≤ `v0.2.1`) must upgrade before receiving these clients. ## Finding 3: 60s upload proofs vs old-relay compatibility Desktop/CLI/mobile upload proofs are now 60s. Older relays re-run full expiry verification after the body is transferred. A valid upload whose body transfer takes >60s will fail on an old relay. **Resolution (relay floor prerequisite):** `RELEASING.md` requires that verified supported deployments (relay releases confirmed to carry the post-body hash-only check, commit `75e9bef748`) be in place before distributing 60s-proof client builds. Unknown or unverified self-hosted relay deployments are excluded from distribution until they upgrade. This makes the verified-deployment status a prerequisite for release distribution, not a post-distribution advisory. The concrete tag (`> v0.2.1`) is to be recorded once cut. ## Sequencing note Builds on #7264 (merged): Blossom strictness is derived from its `config.nip_fi` mode in this PR, so enabling `BUZZ_NIP_FI_MODE=enforce` activates the strict verifier with no further wiring. ## GIF compatibility exception An unauthenticated Off-mode request on a configured tenant with no GIF provider changes from 404 to 401 (base `gifs.rs:269-275` checked an unconfigured provider first; the current code authenticates first). All other Off-mode Blossom responses are byte-identical to pre-NIP-FI behavior. --------- Signed-off-by: Will Pfleger Signed-off-by: Duncan Co-authored-by: Duncan --- RELEASING.md | 39 + crates/buzz-cli/src/client.rs | 19 +- crates/buzz-dev-mcp/src/view_image.rs | 4 +- crates/buzz-media/src/auth.rs | 1570 +++++++++++++++-- crates/buzz-media/src/error.rs | 205 ++- crates/buzz-media/src/lib.rs | 2 +- crates/buzz-media/src/upload.rs | 379 +++- crates/buzz-media/src/validation.rs | 74 + crates/buzz-relay/src/api/media.rs | 1020 ++++++++++- crates/buzz-test-client/tests/e2e_media.rs | 19 +- .../tests/e2e_media_extended.rs | 29 +- .../buzz-test-client/tests/e2e_media_video.rs | 19 +- desktop/src-tauri/src/commands/media.rs | 82 +- desktop/src-tauri/src/media_proxy.rs | 60 + .../media_viewer_page/video_viewer.dart | 138 +- mobile/lib/shared/relay/media_auth.dart | 16 +- mobile/lib/shared/relay/media_upload.dart | 11 +- mobile/pubspec.lock | 6 +- mobile/pubspec.yaml | 4 + .../media_viewer_page/video_viewer_test.dart | 920 ++++++++++ .../video_viewer_transport_test.dart | 169 ++ .../channels/message_content_test.dart | 21 +- mobile/test/helpers/widget_helpers.dart | 10 +- .../test/shared/relay/media_image_test.dart | 27 +- .../test/shared/relay/media_upload_test.dart | 4 +- scripts/test-video-upload.sh | 8 +- 26 files changed, 4544 insertions(+), 311 deletions(-) create mode 100644 mobile/test/features/channels/media_viewer_page/video_viewer_test.dart create mode 100644 mobile/test/features/channels/media_viewer_page/video_viewer_transport_test.dart diff --git a/RELEASING.md b/RELEASING.md index 8d1fad7480..34fc9b49da 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -89,6 +89,45 @@ prior release's recorded squash commit; tag ancestry is deliberately irrelevant. Every push to `main` continues to publish the rolling relay `:main` and `:sha-<7>` tags, plus matching `:debug-main` and `:debug-sha-<7>` variants. +#### NIP-FI upload proof freshness (relay floor) + +Desktop, CLI, and mobile clients mint Blossom upload authorization proofs with +a **60-second expiry** (`expiration = now + 60`). **Older** relays (≤ `v0.2.1`) +re-run the full auth verifier — including the expiry check — after receiving the +full upload body, so a 60-second proof that was valid when admitted can expire +during a large or slow transfer, causing a spurious rejection. + +The repair (commit `75e9bef748d2149ce459b14da842e706a51a5f78`, landed in this +PR) replaces the post-body full-verifier call with a hash-only check +(`verify_upload_hash_only`): the relay verifies the freshness and all other +proof fields before reading the body; the only check after transfer is that the +body's SHA-256 matches what was declared in the signed proof. + +Clients cannot lengthen proofs beyond 60 seconds (NIP-FI §Strict limits the +max proof window to 60 s). This is a relay-side concern: + +- **The relay release that includes this PR's changes** (the first relay + tag > `v0.2.1`, containing commit `75e9bef748d2149ce459b14da842e706a51a5f78`) + performs a hash-only post-body check, eliminating the expiry window during + transfer. **Update this line with the concrete relay tag once it is cut.** +- **Older self-hosted relays** (≤ `v0.2.1`) re-verify the full proof expiry + after transfer and may reject large uploads over slow connections. + Upgrading the relay is the fix; no client workaround exists within Strict + constraints. + +**Prerequisite before distributing desktop, CLI, or mobile builds that mint +60-second upload proofs:** verify that all supported relay environments have +been upgraded to a relay release containing commit +`75e9bef748d2149ce459b14da842e706a51a5f78`. An unknown or unverified deployment +does not satisfy this prerequisite. Deployments running ≤ `v0.2.1` must be +upgraded before receiving these clients. + +When releasing a new **relay** version that carries the post-body hash-only +check, record the concrete tag here so release operators can verify the +prerequisite. When releasing **desktop, CLI, or mobile** for environments where +self-hosted relays may be running pre-floor versions, document the floor in +release notes and require operators to upgrade before distributing these clients. + ### Mobile 1. **Publish a candidate.** From a clean checkout whose `origin` is the diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index 6ade19f1ca..54441f55cc 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -327,7 +327,7 @@ fn sign_blossom_get(keys: &Keys, media_url: &str) -> Result { use nostr::Timestamp; let now = Timestamp::now().as_secs(); - let exp_str = (now + 600).to_string(); + let exp_str = (now + 60).to_string(); let domain = relay_server_tag(media_url) .ok_or_else(|| CliError::Usage(format!("invalid media URL: {media_url}")))?; let tags = vec![ @@ -350,28 +350,23 @@ fn sign_blossom_get(keys: &Keys, media_url: &str) -> Result { fn sign_blossom_upload( keys: &Keys, sha256: &str, - mime: &str, + _mime: &str, relay_url: &str, ) -> Result { use base64::engine::general_purpose::URL_SAFE_NO_PAD; use nostr::Timestamp; let now = Timestamp::now().as_secs(); - let expiry: u64 = if mime.starts_with("video/") { - 3600 - } else { - 600 - }; - let exp_str = (now + expiry).to_string(); + let exp_str = (now + 60).to_string(); + let domain = relay_server_tag(relay_url) + .ok_or_else(|| CliError::Usage(format!("invalid relay URL: {relay_url}")))?; - let mut tags = vec![ + let tags = vec![ Tag::parse(["t", "upload"]).map_err(|e| CliError::Other(e.to_string()))?, Tag::parse(["x", sha256]).map_err(|e| CliError::Other(e.to_string()))?, Tag::parse(["expiration", &exp_str]).map_err(|e| CliError::Other(e.to_string()))?, + Tag::parse(["server", &domain]).map_err(|e| CliError::Other(e.to_string()))?, ]; - if let Some(domain) = relay_server_tag(relay_url) { - tags.push(Tag::parse(["server", &domain]).map_err(|e| CliError::Other(e.to_string()))?); - } let auth_event = EventBuilder::new(Kind::from(24242), "Upload file") .tags(tags) diff --git a/crates/buzz-dev-mcp/src/view_image.rs b/crates/buzz-dev-mcp/src/view_image.rs index 441338ab12..8f8ea0ede7 100644 --- a/crates/buzz-dev-mcp/src/view_image.rs +++ b/crates/buzz-dev-mcp/src/view_image.rs @@ -49,8 +49,8 @@ pub(crate) const MAX_DECODER_ALLOC: u64 = 256 * 1024 * 1024; /// Connect + read timeout for URL fetches. const FETCH_TIMEOUT: Duration = Duration::from_secs(10); /// Lifetime of a Blossom `t=get` read token for relay media fetches. -/// Matches the desktop client's `MEDIA_GET_AUTH_EXPIRY_SECS`. -const MEDIA_GET_AUTH_EXPIRY_SECS: u64 = 600; +/// 60 seconds — matches the NIP-FI strict proof window. +const MEDIA_GET_AUTH_EXPIRY_SECS: u64 = 60; /// Build the decoder allocation cap. Centralised so the resize path uses the /// same value tests can reason about. diff --git a/crates/buzz-media/src/auth.rs b/crates/buzz-media/src/auth.rs index c6fff2be47..96fd1f92c6 100644 --- a/crates/buzz-media/src/auth.rs +++ b/crates/buzz-media/src/auth.rs @@ -1,4 +1,4 @@ -//! Blossom kind:24242 auth verification (BUD-11 compliant). +//! Blossom kind:24242 auth verification (BUD-11 + NIP-FI compliant). use crate::error::MediaError; @@ -18,13 +18,47 @@ impl BlossomVerb { } } -/// Verify common kind:24242 Blossom auth event validity: +/// Verification strictness derived from the NIP-FI mode. +/// +/// `Strict` applies the full NIP-FI kind-24242 rules: 60-second proof window, +/// `expiration <= created_at + 60s`, mandatory `server` tag on all proofs, and +/// exact cardinality (exactly one each of `t`, `expiration`, `server`; at most +/// one `x`). +/// +/// `Permissive` preserves the pre-NIP-FI behavior for Off-mode deployments: +/// 3600-second proof window, `server` tag optional, and duplicate tags accepted. +/// This keeps a deployed desktop that mints old-shape tokens working against an +/// Off-mode relay [FI-INV-15]. +/// +/// The relay call sites derive strictness from `config.nip_fi.mode`; library +/// callers without mode access may pass `Strict` directly. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BlossomStrictness { + /// Full NIP-FI compliance: 60s window, mandatory `server`, exact cardinality. + Strict, + /// Pre-NIP-FI compatibility: 3600s window, optional `server`, tolerant cardinality. + Permissive, +} + +/// Verify common kind:24242 Blossom auth event validity. +/// +/// Checks in order: /// 1. Schnorr signature -/// 2. kind == 24242 -/// 3. `t` tag matches `verb` -/// 4. `expiration` tag in the future -/// 5. `created_at` in the past (with 5s clock-skew tolerance) -/// 6. If `server` tags present, our domain must appear in at least one +/// 2. kind == 24242 and non-empty content +/// 3. `t` tag matches `verb`: +/// - `Strict`: count every tag named `t` by field name; require exactly +/// one occurrence; require its content to be non-empty and equal to +/// `verb` (NIP-FI.md:658-666 — malformed/empty/duplicate instances MUST +/// be rejected as `evidence_rejected`). +/// - `Permissive`: at least one `t` tag equal to `verb`; any other valued +/// `t` (including `""`) rejects and valueless tags are ignored +/// (origin/main `found_t` semantics). +/// 4. `expiration` tag present, strictly future, and within the freshness window +/// 5. `created_at` bounded: not more than 5s in the future; not older than the +/// mode-selected window (60s `Strict`, 3600s `Permissive`) +/// 6. `server` tag enforcement: in `Strict` mode, exactly one `server` tag is +/// required and MUST match the bound tenant host; in `Permissive`, optional +/// when-present behavior is preserved /// /// Does NOT check verb-specific scope tags (`x` for upload, `x` OR `server` /// for get). Call this BEFORE trusting the event's pubkey for scope resolution. @@ -32,8 +66,10 @@ pub fn verify_blossom_auth_event_for_verb( auth_event: &nostr::Event, verb: BlossomVerb, server_domain: Option<&str>, - max_age_secs: u64, + strictness: BlossomStrictness, ) -> Result<(), MediaError> { + let strict = strictness == BlossomStrictness::Strict; + // 1. Verify Schnorr signature auth_event .verify() @@ -49,64 +85,138 @@ pub fn verify_blossom_auth_event_for_verb( return Err(MediaError::InvalidAuthEvent); } - let mut found_t = false; - let mut found_exp = false; - let mut server_tags: Vec<&str> = Vec::new(); + // Tag accumulation with strict cardinality tracking. + // In Strict mode: each of t/expiration/server must appear exactly once; + // x is counted separately for upload scope checking (at most one). + // In Permissive mode: boolean-style "found at least one" semantics, + // matching the pre-NIP-FI behavior. + let mut t_count: u8 = 0; + let mut exp_count: u8 = 0; + let mut server_count: u8 = 0; + let mut x_count: u8 = 0; + let mut exp_value: u64 = 0; + // All server tag values, collected for Permissive any-match semantics. + // Strict mode returns early on duplicate-server before reaching the + // validation block, so server_values[0] is always safe to use there. + let mut server_values: Vec = Vec::new(); for tag in auth_event.tags.iter() { let kind = tag.kind().to_string(); match kind.as_str() { "t" => { - if let Some(v) = tag.content() { - if v != verb.as_str() { - return Err(MediaError::InvalidAuthVerb); + if strict { + // Strict (NIP-FI): count EVERY tag named "t" by field name, + // regardless of its content. NIP-FI.md:658-666 requires + // that malformed, empty, duplicate, or conflicting instances + // be rejected as `evidence_rejected`. We count first, gate + // on cardinality, then validate content. + t_count = t_count.saturating_add(1); + if t_count > 1 { + return Err(MediaError::DuplicateTag("t")); } - found_t = true; + // Exactly one t tag: its content must be non-empty and + // equal to the requested verb. + match tag.content() { + Some(v) if !v.is_empty() && v == verb.as_str() => {} + _ => return Err(MediaError::InvalidAuthVerb), + } + } else { + // Permissive: origin/main's `found_t` semantics exactly. + // Any valued tag (including empty) that is not the verb + // rejects; valueless tags are ignored [FI-INV-15]. + if let Some(v) = tag.content() { + if v != verb.as_str() { + return Err(MediaError::InvalidAuthVerb); + } + t_count = t_count.saturating_add(1); + } + // No content: tag is ignored (not counted, not rejected). } } "expiration" => { + exp_count = exp_count.saturating_add(1); + if strict && exp_count > 1 { + return Err(MediaError::DuplicateTag("expiration")); + } if let Some(v) = tag.content() { - exp_value = v.parse().unwrap_or(0); - found_exp = true; + if strict { + // Strict: first-wins (duplicate already rejected above). + if exp_count == 1 { + exp_value = v.parse().unwrap_or(0); + } + } else { + // Permissive: last-wins, matching pre-NIP-FI base + // behavior (base auth.rs processed each valued tag + // unconditionally, so a later tag overwrote earlier + // ones). A valueless tag followed by a future-valued + // tag must not be treated as expired [FI-INV-15]. + exp_value = v.parse().unwrap_or(0); + } } } "server" => { + server_count = server_count.saturating_add(1); + if strict && server_count > 1 { + return Err(MediaError::DuplicateTag("server")); + } + // Collect all valued server tags for Permissive any-match; Strict + // will only ever have at most one here (duplicate check above). if let Some(v) = tag.content() { - server_tags.push(v); + server_values.push(v.to_owned()); + } + } + "x" => { + x_count = x_count.saturating_add(1); + if strict && x_count > 1 { + return Err(MediaError::DuplicateTag("x")); } } _ => {} } } - // 3. t tag required - if !found_t { + // 3. t tag required (exactly one in Strict, at least one in Permissive) + if t_count == 0 { return Err(MediaError::MissingTag("t")); } - // 4. Expiration must exist and be in the future - if !found_exp { + // 4a. Expiration must exist + if exp_count == 0 { return Err(MediaError::MissingTag("expiration")); } let now = nostr::Timestamp::now().as_secs(); + + // 4b. Expiration must be strictly in the future if exp_value <= now { return Err(MediaError::TokenExpired); } - // 5. created_at must be recent: not in the future (5s tolerance) and not - // older than 10 minutes. This bounds the replay window — even if the - // expiration tag allows a longer lifetime, the token must have been - // freshly minted. + // 5. created_at freshness bounds. + // + // Strict (NIP-FI active modes): + // created_at <= now + 5s — bounded future skew + // now - created_at <= 60s — 60-second replay window + // expiration <= created_at + 60s — token cannot outlive its window + // + // Permissive (Off mode): + // created_at <= now + 5s — future skew only + // now - created_at <= 3600s — 1-hour replay window (pre-NIP-FI) + // expiration window not enforced let created = auth_event.created_at.as_secs(); if created > now + 5 { return Err(MediaError::TimestampOutOfWindow); } - if now > created + max_age_secs { + let max_age = if strict { 60u64 } else { 3600u64 }; + if now > created + max_age { + return Err(MediaError::TimestampOutOfWindow); + } + if strict && exp_value > created + 60 { + // expiration tag must satisfy expiration <= created_at + 60s return Err(MediaError::TimestampOutOfWindow); } - // 6. Server tag enforcement (BUD-11 §5): if server tags present, our host must appear. + // 6. Server tag enforcement. // // `server_domain` is the host this request was bound to — the per-request // tenant host (`TenantContext::host()`), NOT a single process-global domain. @@ -117,21 +227,59 @@ pub fn verify_blossom_auth_event_for_verb( // construction across case, trailing dot, default ports, and an optional // URL scheme/path — exactly as every other host seam resolves tenants. // - // Fail closed: if the bound host is unknown, reject tokens that carry server - // tags rather than silently accepting them. - if !server_tags.is_empty() { - match server_domain { - Some(domain) => { - let want = normalize_server_host(domain); - let matches = server_tags - .iter() - .any(|tag| normalize_server_host(tag) == want); - if !matches { + // Strict (NIP-FI active): exactly one server tag MUST be present and MUST + // match the bound tenant host. Absent or mismatched → evidence_rejected. + // + // Permissive (Off mode): if ANY server tag is present, at least one MUST + // match our host (any-match semantics, preserving origin/main behavior); + // absent server tags are accepted [FI-INV-15]. + if strict { + // Strict: server_values has at most one entry (duplicate rejected above). + match ( + server_count, + server_values.first().map(String::as_str), + server_domain, + ) { + (0, _, _) => { + // Strict: server tag mandatory + return Err(MediaError::ServerMismatch); + } + (_, Some(tag_host), Some(domain)) => { + if normalize_server_host(tag_host) != normalize_server_host(domain) { return Err(MediaError::ServerMismatch); } } - None => { - // Server tags present but we don't know our own host — reject. + (_, _, None) => { + // Strict: bound host unknown — fail closed + return Err(MediaError::ServerMismatch); + } + (_, None, _) => { + // server tag present but valueless — treat as mismatch + return Err(MediaError::ServerMismatch); + } + } + } else { + // Permissive: validate only when VALUED server tags are present. + // A valueless `["server"]` tag does not constitute a server-tag binding + // and must not change the admission decision (FI-INV-15: Permissive/Off + // must preserve pre-NIP-FI base behavior, which gated on the collected + // valued-tag list being nonempty). Using `server_values.is_empty()` + // (not `server_count > 0`) ensures a lone valueless server tag is + // treated the same as no server tag — the proof is admitted without a + // server check, matching origin/main behavior. + // + // Any-match semantics: a proof with multiple VALUED server tags is + // accepted if at least one matches our host. + if !server_values.is_empty() { + let Some(domain) = server_domain else { + // Server tags present but our host is unknown — fail closed. + return Err(MediaError::ServerMismatch); + }; + let want = normalize_server_host(domain); + let any_match = server_values + .iter() + .any(|tag_host| normalize_server_host(tag_host) == want); + if !any_match { return Err(MediaError::ServerMismatch); } } @@ -140,6 +288,28 @@ pub fn verify_blossom_auth_event_for_verb( Ok(()) } +/// Verify only the `x` tag hash match on an already-admitted upload auth event. +/// +/// This is the post-body hash check: the full auth event verification +/// (signature, kind, freshness, cardinality, server) was already performed at +/// the pre-body admission gate. Re-running the full verifier after streaming +/// a potentially large body would fail for any upload that takes longer than +/// the minted token's `expiration` window (typically 60 s in Strict mode). +/// +/// The ONLY thing that is unknown before the body is transferred is the +/// content hash (`x` tag). This function confirms that the body's SHA-256 +/// matches what was declared in the signed proof. +pub fn verify_upload_hash_only(auth_event: &nostr::Event, sha256: &str) -> Result<(), MediaError> { + let has_matching_x = auth_event + .tags + .iter() + .any(|tag| tag.kind().to_string() == "x" && tag.content() == Some(sha256)); + if !has_matching_x { + return Err(MediaError::HashMismatch); + } + Ok(()) +} + /// Verify common upload auth event validity. /// /// Kept as the upload-shaped public wrapper for existing callers; new verb-aware @@ -147,9 +317,9 @@ pub fn verify_blossom_auth_event_for_verb( pub fn verify_blossom_auth_event( auth_event: &nostr::Event, server_domain: Option<&str>, - max_age_secs: u64, + strictness: BlossomStrictness, ) -> Result<(), MediaError> { - verify_blossom_auth_event_for_verb(auth_event, BlossomVerb::Upload, server_domain, max_age_secs) + verify_blossom_auth_event_for_verb(auth_event, BlossomVerb::Upload, server_domain, strictness) } /// Normalize a Blossom `server` tag value (or a bound tenant host) into the @@ -170,22 +340,19 @@ fn normalize_server_host(value: &str) -> String { /// Verify a kind:24242 Blossom upload auth event, including the x tag hash check. /// -/// Calls [`verify_blossom_auth_event`] first, then verifies that at least one -/// `x` tag matches `sha256` (BUD-11 §6: "at least one x tag matches"). +/// In `Strict` mode (NIP-FI active): exactly one `x` tag MUST be present and +/// MUST match `sha256`. In `Permissive` mode: at least one `x` tag must match. pub fn verify_blossom_upload_auth( auth_event: &nostr::Event, sha256: &str, server_domain: Option<&str>, - max_age_secs: u64, + strictness: BlossomStrictness, ) -> Result<(), MediaError> { - verify_blossom_auth_event_for_verb( - auth_event, - BlossomVerb::Upload, - server_domain, - max_age_secs, - )?; + verify_blossom_auth_event_for_verb(auth_event, BlossomVerb::Upload, server_domain, strictness)?; - // At least one x tag must match the body sha256 (BUD-11 §6) + // Upload: x tag must match the body sha256. + // Strict: exactly one x tag and it must match (duplicate x caught above). + // Permissive: at least one x tag must match. let has_matching_x = auth_event .tags .iter() @@ -204,18 +371,39 @@ pub fn verify_blossom_upload_auth( /// or server-scoped authorization (`server` tag matches this relay host). The /// latter intentionally grants reads for all blobs on the host until expiration; /// callers must still apply relay membership after this verifier returns. +/// +/// In `Strict` mode the `server` tag is mandatory (enforced by the base verifier); +/// `x` is optional on reads. An `x` tag, when present, must be exactly one and +/// must match `sha256`. pub fn verify_blossom_get_auth( auth_event: &nostr::Event, sha256: &str, server_domain: Option<&str>, - max_age_secs: u64, + strictness: BlossomStrictness, ) -> Result<(), MediaError> { - verify_blossom_auth_event_for_verb(auth_event, BlossomVerb::Get, server_domain, max_age_secs)?; + verify_blossom_auth_event_for_verb(auth_event, BlossomVerb::Get, server_domain, strictness)?; - let has_matching_x = auth_event + // x tag scope check for get: if an x tag is present it must match sha256. + // In Strict mode duplicate x is already rejected above; here we check the value. + // In Permissive mode we use the original "any matching x OR matching server" logic. + + // Count ALL x tags (including valueless) — presence of a valueless x in Strict + // mode is a malformed proof and must not become absent scope. + let x_tag_count: usize = auth_event .tags .iter() - .any(|tag| tag.kind().to_string() == "x" && (tag.content() == Some(sha256))); + .filter(|tag| tag.kind().to_string() == "x") + .count(); + + // Collect only valued x tags for hash comparison. + let x_tags: Vec<&str> = auth_event + .tags + .iter() + .filter(|tag| tag.kind().to_string() == "x") + .filter_map(|tag| tag.content()) + .collect(); + + let has_matching_x = x_tags.contains(&sha256); let has_matching_server = match server_domain { Some(domain) => { @@ -231,8 +419,20 @@ pub fn verify_blossom_get_auth( None => false, }; - if !has_matching_x && !has_matching_server { - return Err(MediaError::InsufficientScope); + if strictness == BlossomStrictness::Strict { + // Strict: server is already validated as present+matching by the base verifier. + // If any x tag is present (including a valueless one), it MUST contain the + // exact requested sha256 — a valueless or mismatched x is malformed evidence + // (evidence_rejected). No x tags → server-scoped read, admitted here. + if x_tag_count > 0 && !has_matching_x { + return Err(MediaError::ServerMismatch); + } + // Server-scoped read (no x) is always admitted here — server was validated above. + } else { + // Permissive: original BUD-01 semantics — matching x OR matching server. + if !has_matching_x && !has_matching_server { + return Err(MediaError::InsufficientScope); + } } Ok(()) @@ -242,8 +442,25 @@ pub fn verify_blossom_get_auth( mod tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + use sha2::Digest as _; fn build_valid_auth(keys: &Keys, sha256: &str) -> nostr::Event { + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(keys) + .unwrap() + } + + fn build_permissive_auth(keys: &Keys, sha256: &str) -> nostr::Event { + // Old-shape token: no server tag, 300s expiry — valid in Permissive, rejected in Strict. let now = Timestamp::now().as_secs(); let exp_str = (now + 300).to_string(); let tags = vec![ @@ -257,22 +474,349 @@ mod tests { .unwrap() } + // ── Strict mode ────────────────────────────────────────────────────────── + #[test] - fn test_verify_valid() { + fn test_verify_valid_strict() { let keys = Keys::generate(); let sha256 = "a".repeat(64); let event = build_valid_auth(&keys, &sha256); - assert!(verify_blossom_upload_auth(&event, &sha256, None, 600).is_ok()); + assert!(verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ) + .is_ok()); } #[test] - fn test_verify_auth_event_valid() { + fn test_verify_auth_event_valid_strict() { let keys = Keys::generate(); let sha256 = "a".repeat(64); let event = build_valid_auth(&keys, &sha256); - assert!(verify_blossom_auth_event(&event, None, 600).is_ok()); + assert!(verify_blossom_auth_event( + &event, + Some("relay.example"), + BlossomStrictness::Strict + ) + .is_ok()); } + // ── Permissive mode (Off-mode regression guard [FI-INV-15]) ────────────── + + #[test] + fn test_verify_permissive_old_shape_token_admitted() { + // Old-shape token (no server tag, 300s expiry) MUST be admitted in Permissive mode. + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let event = build_permissive_auth(&keys, &sha256); + assert!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ) + .is_ok(), + "Off-mode must admit old-shape tokens without server tag [FI-INV-15]" + ); + } + + #[test] + fn test_verify_permissive_long_expiry_admitted() { + // 600s expiry token (pre-NIP-FI desktop default) MUST be admitted in Permissive mode. + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 600).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ) + .is_ok(), + "Off-mode must admit 600s expiry tokens [FI-INV-15]" + ); + } + + // ── Cardinality: Strict rejects duplicates ──────────────────────────────── + + #[test] + fn test_strict_rejects_duplicate_t_tag() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["t", "upload"]).unwrap(), // duplicate + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!(matches!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ), + Err(MediaError::DuplicateTag("t")) + )); + } + + #[test] + fn test_strict_rejects_duplicate_expiration_tag() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), // duplicate + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!(matches!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ), + Err(MediaError::DuplicateTag("expiration")) + )); + } + + #[test] + fn test_strict_rejects_duplicate_server_tag() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), // duplicate + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!(matches!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ), + Err(MediaError::DuplicateTag("server")) + )); + } + + #[test] + fn test_strict_rejects_duplicate_x_tag() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), // duplicate + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!(matches!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ), + Err(MediaError::DuplicateTag("x")) + )); + } + + // ── Permissive mode: duplicate tags still admitted ──────────────────────── + + #[test] + fn test_permissive_admits_duplicate_x_tag() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), // duplicate — permitted in Permissive + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!(verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ) + .is_ok()); + } + + // ── Strict: server tag mandatory ───────────────────────────────────────── + + #[test] + fn test_strict_rejects_absent_server_on_upload() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + // no server tag + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + matches!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ), + Err(MediaError::ServerMismatch) + ), + "Strict mode must reject upload proof without server tag" + ); + } + + #[test] + fn test_strict_rejects_absent_server_on_read() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t", "get"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + // no server tag + ]; + let event = EventBuilder::new(Kind::from(24242), "Get buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + matches!( + verify_blossom_get_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ), + Err(MediaError::ServerMismatch) + ), + "Strict mode must reject read proof without server tag" + ); + } + + // ── Strict: freshness window ────────────────────────────────────────────── + + #[test] + fn test_strict_rejects_expiration_exceeding_60s_window() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 61).to_string(); // 61s > 60s limit + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + matches!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ), + Err(MediaError::TimestampOutOfWindow) + ), + "Strict mode must reject expiration > created_at + 60s" + ); + } + + #[test] + fn test_strict_admits_60s_expiration() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 60).to_string(); // exactly 60s — allowed + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ) + .is_ok(), + "Strict mode must admit expiration == created_at + 60s" + ); + } + + // ── Get auth ───────────────────────────────────────────────────────────── + fn build_get_auth(keys: &Keys, tags: Vec) -> nostr::Event { EventBuilder::new(Kind::from(24242), "Get buzz-media") .tags(tags) @@ -281,7 +825,82 @@ mod tests { } #[test] - fn test_verify_get_accepts_matching_x_without_server_tag() { + fn test_verify_get_accepts_matching_server_strict() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let event = build_get_auth( + &keys, + vec![ + Tag::parse(["t", "get"]).unwrap(), + Tag::parse(["server", "https://Relay.Example./media/ignored"]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + ], + ); + assert!(verify_blossom_get_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ) + .is_ok()); + } + + #[test] + fn test_verify_get_accepts_matching_x_and_server_strict() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let event = build_get_auth( + &keys, + vec![ + Tag::parse(["t", "get"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + ], + ); + assert!(verify_blossom_get_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ) + .is_ok()); + } + + #[test] + fn test_verify_get_strict_rejects_mismatched_x_with_valid_server() { + // x present but wrong hash → evidence_rejected even if server matches + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let other = "b".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let event = build_get_auth( + &keys, + vec![ + Tag::parse(["t", "get"]).unwrap(), + Tag::parse(["x", &other]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + ], + ); + assert!(matches!( + verify_blossom_get_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ), + Err(MediaError::ServerMismatch) + )); + } + + #[test] + fn test_verify_get_accepts_matching_x_without_server_permissive() { let keys = Keys::generate(); let sha256 = "a".repeat(64); let now = Timestamp::now().as_secs(); @@ -294,12 +913,17 @@ mod tests { Tag::parse(["expiration", &exp_str]).unwrap(), ], ); - - assert!(verify_blossom_get_auth(&event, &sha256, Some("relay.example"), 600).is_ok()); + assert!(verify_blossom_get_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ) + .is_ok()); } #[test] - fn test_verify_get_accepts_matching_server_without_x_tag() { + fn test_verify_get_accepts_matching_server_without_x_permissive() { let keys = Keys::generate(); let sha256 = "a".repeat(64); let now = Timestamp::now().as_secs(); @@ -312,8 +936,13 @@ mod tests { Tag::parse(["expiration", &exp_str]).unwrap(), ], ); - - assert!(verify_blossom_get_auth(&event, &sha256, Some("relay.example"), 600).is_ok()); + assert!(verify_blossom_get_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ) + .is_ok()); } #[test] @@ -321,15 +950,19 @@ mod tests { let keys = Keys::generate(); let sha256 = "a".repeat(64); let event = build_valid_auth(&keys, &sha256); - assert!(matches!( - verify_blossom_get_auth(&event, &sha256, Some("relay.example"), 600), + verify_blossom_get_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ), Err(MediaError::InvalidAuthVerb) )); } #[test] - fn test_verify_get_requires_x_or_server_scope() { + fn test_verify_get_requires_x_or_server_scope_permissive() { let keys = Keys::generate(); let sha256 = "a".repeat(64); let other_hash = "b".repeat(64); @@ -343,9 +976,13 @@ mod tests { Tag::parse(["expiration", &exp_str]).unwrap(), ], ); - assert!(matches!( - verify_blossom_get_auth(&event, &sha256, Some("relay.example"), 600), + verify_blossom_get_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ), Err(MediaError::InsufficientScope) )); } @@ -364,9 +1001,13 @@ mod tests { Tag::parse(["expiration", &exp_str]).unwrap(), ], ); - assert!(matches!( - verify_blossom_get_auth(&event, &sha256, Some("relay.example"), 600), + verify_blossom_get_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ), Err(MediaError::ServerMismatch) )); } @@ -378,7 +1019,12 @@ mod tests { let event = build_valid_auth(&keys, &sha256); let wrong_hash = "b".repeat(64); assert!(matches!( - verify_blossom_upload_auth(&event, &wrong_hash, None, 600), + verify_blossom_upload_auth( + &event, + &wrong_hash, + Some("relay.example"), + BlossomStrictness::Strict + ), Err(MediaError::HashMismatch) )); } @@ -388,45 +1034,30 @@ mod tests { let keys = Keys::generate(); let sha256 = "a".repeat(64); let now = Timestamp::now().as_secs(); - let exp_str = (now + 300).to_string(); + let exp_str = (now + 55).to_string(); let tags = vec![ Tag::parse(["t", "upload"]).unwrap(), Tag::parse(["x", &sha256]).unwrap(), Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), ]; let event = EventBuilder::new(Kind::from(27235), "wrong kind") .tags(tags) .sign_with_keys(&keys) .unwrap(); assert!(matches!( - verify_blossom_upload_auth(&event, &sha256, None, 600), + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ), Err(MediaError::InvalidAuthKind) )); } #[test] - fn test_verify_multi_x_tags() { - let keys = Keys::generate(); - let sha256 = "a".repeat(64); - let other_hash = "b".repeat(64); - let now = Timestamp::now().as_secs(); - let exp_str = (now + 300).to_string(); - let tags = vec![ - Tag::parse(["t", "upload"]).unwrap(), - Tag::parse(["x", &other_hash]).unwrap(), - Tag::parse(["x", &sha256]).unwrap(), - Tag::parse(["expiration", &exp_str]).unwrap(), - ]; - let event = EventBuilder::new(Kind::from(24242), "Upload multi-x") - .tags(tags) - .sign_with_keys(&keys) - .unwrap(); - // Should pass because at least one x tag matches - assert!(verify_blossom_upload_auth(&event, &sha256, None, 600).is_ok()); - } - - #[test] - fn test_server_tag_enforcement() { + fn test_server_tag_enforcement_permissive() { let keys = Keys::generate(); let sha256 = "a".repeat(64); let now = Timestamp::now().as_secs(); @@ -443,41 +1074,52 @@ mod tests { .unwrap(); // Should fail — server tag present but doesn't match our domain assert!(matches!( - verify_blossom_upload_auth(&event, &sha256, Some("buzz.example.com"), 600), + verify_blossom_upload_auth( + &event, + &sha256, + Some("buzz.example.com"), + BlossomStrictness::Permissive + ), Err(MediaError::ServerMismatch) )); // Should pass when our domain matches - assert!( - verify_blossom_upload_auth(&event, &sha256, Some("other.example.com"), 600).is_ok() - ); + assert!(verify_blossom_upload_auth( + &event, + &sha256, + Some("other.example.com"), + BlossomStrictness::Permissive + ) + .is_ok()); // Should fail when server_domain is None — fail closed assert!(matches!( - verify_blossom_upload_auth(&event, &sha256, None, 600), + verify_blossom_upload_auth(&event, &sha256, None, BlossomStrictness::Permissive), Err(MediaError::ServerMismatch) )); } #[test] - fn test_no_server_tags_always_passes() { + fn test_permissive_no_server_tags_always_passes() { let keys = Keys::generate(); let sha256 = "a".repeat(64); - let event = build_valid_auth(&keys, &sha256); - // No server tags → passes regardless of our domain - assert!(verify_blossom_upload_auth(&event, &sha256, Some("any.domain.com"), 600).is_ok()); + let event = build_permissive_auth(&keys, &sha256); + // No server tags → passes regardless of our domain in Permissive mode + assert!(verify_blossom_upload_auth( + &event, + &sha256, + Some("any.domain.com"), + BlossomStrictness::Permissive + ) + .is_ok()); } /// A `server` tag is matched against the *bound tenant host* under the - /// shared `normalize_host` rule, so equivalent host spellings agree — the - /// stock CLI's bare `host:port`, an explicit default port, a trailing dot, - /// mixed case, and a full URL all match the same bound host. This is the - /// regression guard for the multi-tenant media blocker: a non-primary - /// tenant must accept its own server-tagged client. + /// shared `normalize_host` rule, so equivalent host spellings agree. #[test] fn test_server_tag_normalized_against_bound_host() { let keys = Keys::generate(); let sha256 = "a".repeat(64); let now = Timestamp::now().as_secs(); - let exp_str = (now + 300).to_string(); + let exp_str = (now + 55).to_string(); let build = |server: &str| { let tags = vec![ Tag::parse(["t", "upload"]).unwrap(), @@ -491,18 +1133,16 @@ mod tests { .unwrap() }; - // Non-primary tenant host with explicit non-default port (the live - // repro: tenant B on 127.0.0.1:3100). Stock CLI tags `host:port`. + // Non-primary tenant host with explicit non-default port. assert!(verify_blossom_upload_auth( &build("127.0.0.1:3100"), &sha256, Some("127.0.0.1:3100"), - 600 + BlossomStrictness::Strict ) .is_ok()); - // Equivalence under normalize_host: explicit default port, trailing - // dot, mixed case, and a full URL all collapse to the bound host. + // Equivalence under normalize_host for tag in [ "Relay.Example:443", "relay.example.", @@ -510,8 +1150,13 @@ mod tests { "https://relay.example/", ] { assert!( - verify_blossom_upload_auth(&build(tag), &sha256, Some("relay.example"), 600) - .is_ok(), + verify_blossom_upload_auth( + &build(tag), + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ) + .is_ok(), "server tag {tag:?} should match bound host relay.example" ); } @@ -522,7 +1167,7 @@ mod tests { &build("127.0.0.1:3100"), &sha256, Some("127.0.0.1:3200"), - 600 + BlossomStrictness::Strict ), Err(MediaError::ServerMismatch) )); @@ -533,11 +1178,12 @@ mod tests { let keys = Keys::generate(); let sha256 = "a".repeat(64); let now = Timestamp::now().as_secs(); - let exp_str = (now + 300).to_string(); + let exp_str = (now + 55).to_string(); let tags = vec![ Tag::parse(["t", "upload"]).unwrap(), Tag::parse(["x", &sha256]).unwrap(), Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), ]; // Empty content — BUD-11 requires a human-readable string let event = EventBuilder::new(Kind::from(24242), "") @@ -545,8 +1191,682 @@ mod tests { .sign_with_keys(&keys) .unwrap(); assert!(matches!( - verify_blossom_auth_event(&event, None, 600), + verify_blossom_auth_event(&event, Some("relay.example"), BlossomStrictness::Strict), Err(MediaError::InvalidAuthEvent) )); } + + // ── Finding 3: valueless / empty-string t tag must not satisfy verb binding ── + + /// A `t` tag with no content (`["t"]`) in Strict mode: counted as one occurrence, + /// content check fires → `InvalidAuthVerb` (malformed instance, NIP-FI §cardinality). + #[test] + fn test_strict_rejects_valueless_t_tag() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + // ["t"] with no second element — no content, must be rejected + let tags = vec![ + Tag::parse(["t"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + matches!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ), + Err(MediaError::InvalidAuthVerb) + ), + "valueless t tag must be rejected in Strict mode (counted once, content invalid)" + ); + } + + #[test] + fn test_valueless_t_tag_is_not_counted_permissive() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 300).to_string(); + let tags = vec![ + Tag::parse(["t"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + matches!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ), + Err(MediaError::MissingTag("t")) + ), + "valueless t tag must not satisfy t requirement in Permissive mode" + ); + } + + /// A `t` tag with an empty-string value (`["t", ""]`) in Strict mode: counted + /// as one occurrence, content check fires → `InvalidAuthVerb`. + #[test] + fn test_strict_rejects_empty_string_t_tag() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t", ""]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + matches!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ), + Err(MediaError::InvalidAuthVerb) + ), + "empty-string t tag must be rejected in Strict mode (counted once, content invalid)" + ); + } + + /// A valueless `x` tag on upload (`["x"]`) does not match any sha256. + #[test] + fn test_valueless_x_tag_does_not_match_hash() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x"]).unwrap(), // no value + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + matches!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ), + Err(MediaError::HashMismatch) + ), + "valueless x tag must not satisfy hash requirement" + ); + } + + // ── Finding 3 R2: mixed malformed+valid t combos must reject in Strict ──── + + /// `["t"]` (valueless) + `["t","upload"]` in Strict: the malformed tag is counted + /// and content-validated on first encounter; the exact rejection error depends on + /// tag ordering but any error is correct — no combination may be admitted. + /// (If malformed comes first: `InvalidAuthVerb`; if valid first: `DuplicateTag("t")`.) + #[test] + fn test_strict_rejects_valueless_plus_valid_t_combo() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t"]).unwrap(), // valueless — counted in Strict + Tag::parse(["t", "upload"]).unwrap(), // valid — but two t tags total + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload bypass attempt") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + let result = verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict, + ); + assert!( + result.is_err(), + "valueless+valid t combo must be rejected in Strict mode, got Ok" + ); + // The first tag is valueless → InvalidAuthVerb fires before the second tag + // is seen; if ordering were reversed it would be DuplicateTag("t"). Both are + // valid evidence_rejected-class outcomes — what matters is admission is denied. + assert!( + matches!( + result, + Err(MediaError::InvalidAuthVerb) | Err(MediaError::DuplicateTag("t")) + ), + "expected InvalidAuthVerb or DuplicateTag(t), got unexpected error variant" + ); + } + + /// `["t",""]` (empty-string) + `["t","upload"]` in Strict: same reasoning — + /// any error is correct; admission must be denied. + #[test] + fn test_strict_rejects_empty_plus_valid_t_combo() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t", ""]).unwrap(), // empty-string — counted in Strict + Tag::parse(["t", "upload"]).unwrap(), // valid — but two t tags total + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload bypass attempt") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + let result = verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict, + ); + assert!( + result.is_err(), + "empty-string+valid t combo must be rejected in Strict mode, got Ok" + ); + assert!( + matches!( + result, + Err(MediaError::InvalidAuthVerb) | Err(MediaError::DuplicateTag("t")) + ), + "expected InvalidAuthVerb or DuplicateTag(t), got unexpected error variant" + ); + } + + /// Permissive must match origin/main's predicate exactly [FI-INV-15]: any + /// valued `t` unequal to the verb — including `""` — rejects, in either + /// order; only a valueless `["t"]` is ignored. + fn permissive_get_with_t_tags(t_tags: &[&[&str]]) -> Result<(), MediaError> { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let exp_str = (Timestamp::now().as_secs() + 300).to_string(); + let mut tags: Vec = t_tags + .iter() + .map(|t| Tag::parse(t.iter().copied()).unwrap()) + .collect(); + tags.push(Tag::parse(["x", &sha256]).unwrap()); + tags.push(Tag::parse(["expiration", &exp_str]).unwrap()); + let event = build_get_auth(&keys, tags); + verify_blossom_get_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive, + ) + } + + #[test] + fn test_permissive_rejects_empty_plus_valid_t_either_order() { + for order in [ + &[&["t", ""][..], &["t", "get"][..]][..], + &[&["t", "get"][..], &["t", ""][..]][..], + ] { + assert!( + matches!( + permissive_get_with_t_tags(order), + Err(MediaError::InvalidAuthVerb) + ), + "Permissive must reject empty-valued t like main: {order:?}" + ); + } + } + + #[test] + fn test_permissive_admits_valueless_plus_valid_t_either_order() { + for order in [ + &[&["t"][..], &["t", "get"][..]][..], + &[&["t", "get"][..], &["t"][..]][..], + ] { + assert!( + permissive_get_with_t_tags(order).is_ok(), + "Permissive must ignore valueless t like main: {order:?}" + ); + } + } + + /// `["x"]` (valueless) + `["x", sha256]` (valid) in Strict: `x_count` increments + /// unconditionally for both occurrences → `DuplicateTag("x")`. This confirms + /// the x arm already handles mixed malformed+valid correctly. + #[test] + fn test_strict_rejects_valueless_plus_valid_x_combo() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x"]).unwrap(), // valueless — counted unconditionally + Tag::parse(["x", &sha256]).unwrap(), // valid — but makes x_count = 2 + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload bypass attempt") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + matches!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ), + Err(MediaError::DuplicateTag("x")) + ), + "valueless+valid x combo must be rejected as DuplicateTag in Strict mode" + ); + } + + // ── Finding 1 (F1): Permissive multi-server any-match regression ───────── + + /// Permissive: `[[\"server\",\"other.example\"],[\"server\",\"relay.example\"]]` — + /// relay.example is second; must be admitted (any-match). Restores origin/main semantics. + #[test] + fn test_permissive_multi_server_second_matches() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 300).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "other.example"]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload multi-server") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ) + .is_ok(), + "Permissive must accept multi-server proof when any server matches (relay.example second)" + ); + } + + /// Permissive: `[[\"server\",\"relay.example\"],[\"server\",\"other.example\"]]` — + /// relay.example is first; must also be admitted. + #[test] + fn test_permissive_multi_server_first_matches() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 300).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "relay.example"]).unwrap(), + Tag::parse(["server", "other.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload multi-server") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ) + .is_ok(), + "Permissive must accept multi-server proof when any server matches (relay.example first)" + ); + } + + /// Permissive multi-server: neither server matches our host → still rejected. + #[test] + fn test_permissive_multi_server_none_match() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 300).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", "other.example"]).unwrap(), + Tag::parse(["server", "another.example"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload multi-server") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + matches!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ), + Err(MediaError::ServerMismatch) + ), + "Permissive must reject multi-server proof when no server matches" + ); + } + + // ── Finding 4 (F4): valueless x tag on Strict get must not become host-wide scope ─ + + /// Strict get: proof with exactly one `[\"x\"]` (valueless) and a valid server — + /// the valueless x tag must not become absent scope (host-wide authorization). + /// Before the fix, `filter_map(tag.content())` dropped it silently and the read passed. + #[test] + fn test_strict_get_valueless_x_tag_does_not_grant_host_wide_scope() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 55).to_string(); + let tags = vec![ + Tag::parse(["t", "get"]).unwrap(), + Tag::parse(["x"]).unwrap(), // valueless — must not become absent scope + Tag::parse(["server", "relay.example"]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Get buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + // Must be rejected: valueless x is present but matches no sha256. + assert!( + verify_blossom_get_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ) + .is_err(), + "valueless x tag in Strict get must not grant host-wide read scope" + ); + } + + // ── Finding 1 compatibility edge (F1): valueless server tag must not change Permissive/Off behavior ─ + + /// Permissive: a proof with a valid matching `x`, a valid expiration, and a + /// lone VALUELESS `["server"]` tag (no content) was admitted by the pre-NIP-FI + /// base verifier, which gated server validation on the collected valued-tag list + /// being nonempty. The fix gates on `!server_values.is_empty()` (not + /// `server_count > 0`) so a valueless server tag is treated as absent. + /// + /// Before the fix, `server_count > 0` was true (count includes valueless tags), + /// entering the branch, and `server_values.iter().any(...)` over an empty vec + /// always returned false → `ServerMismatch` — a regression vs. base behavior. + #[test] + fn test_permissive_valueless_server_tag_does_not_change_admission() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 300).to_string(); + // Valid proof: matching x, valid expiration, lone valueless server tag. + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server"]).unwrap(), // valueless — must be ignored in Permissive + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + // Permissive: valueless server tag must not trigger ServerMismatch. + // The proof has a matching x tag so it must be admitted on the x-scope path. + assert!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ) + .is_ok(), + "Permissive must admit a proof whose only server tag is valueless — \ + valueless server tag must not change admission (FI-INV-15)" + ); + } + + /// Permissive (used for both Permissive and Off NIP-FI modes on the Blossom path): + /// same invariant as above, tested on its own to confirm the fix is not + /// order-dependent. Permissive is the only non-Strict variant; Off mode + /// selects Permissive strictness at the API layer. + #[test] + fn test_off_valueless_server_tag_does_not_change_admission() { + let keys = Keys::generate(); + let sha256 = "b".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 300).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server"]).unwrap(), // valueless + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + // BlossomStrictness::Off is represented as Permissive on the Blossom path. + assert!( + verify_blossom_upload_auth( + &event, + &sha256, + Some("relay.example"), + BlossomStrictness::Permissive + ) + .is_ok(), + "Off must admit a proof whose only server tag is valueless (FI-INV-15)" + ); + } + + // ── Upload expiry-boundary regression (commit 75e9bef748d2149ce459b14da842e706a51a5f78) ── + // + // Demonstrates the split between pre-body admission (freshness + full auth) + // and post-body verification (hash only via verify_upload_hash_only). + // + // Scenario: a 60-second Strict upload proof is valid at admit time. After a + // slow transfer, the proof's expiry has passed. The OLD full-verifier + // (verify_blossom_upload_auth called post-body) would reject the expired + // token, failing a legitimate large upload. The NEW post-body gate + // (verify_upload_hash_only) ignores freshness and accepts only the hash. + // + // Three cases exercise the production upload path contract: + // A. Admitted when fresh → pre-body gate (verify_blossom_auth_event_for_verb) accepts. + // B. Old full-verifier post-body → rejects even a valid hash if the token expired. + // C. New hash-only post-body → accepts the matching hash regardless of expiry. + // D. Hash-mismatch control → verify_upload_hash_only rejects a wrong hash. + + #[test] + fn test_upload_expiry_boundary_admission_before_expiry() { + // Case A: the pre-body gate must accept a fresh 60-second proof. + let keys = Keys::generate(); + let body = b"hello world"; + let sha256 = hex::encode(sha2::Sha256::digest(body)); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 60).to_string(); // exactly at the Strict ceiling + let server = "relay.example"; + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", server]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + verify_blossom_auth_event_for_verb( + &event, + BlossomVerb::Upload, + Some(server), + BlossomStrictness::Strict, + ) + .is_ok(), + "pre-body gate must admit a fresh 60-second Strict upload proof" + ); + } + + #[test] + fn test_upload_expiry_boundary_old_verifier_rejects_expired_post_body() { + // Case B: the OLD full-verifier rejects an expired token post-body, even + // when the hash matches. This is the failure the hash-only split fixes. + let keys = Keys::generate(); + let body = b"hello world"; + let sha256 = hex::encode(sha2::Sha256::digest(body)); + let server = "relay.example"; + // Token expired in the past — simulates a proof that was valid at admit + // time but whose expiry passed during a slow body transfer. + let past = Timestamp::now().as_secs().saturating_sub(10); + let exp_str = past.to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", server]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + // Full verifier (old post-body path) rejects the expired token. + assert!( + matches!( + verify_blossom_upload_auth( + &event, + &sha256, + Some(server), + BlossomStrictness::Strict + ), + Err(MediaError::TokenExpired) + ), + "old full-verifier must reject an expired token even when the hash matches" + ); + } + + #[test] + fn test_upload_expiry_boundary_hash_only_accepts_expired_after_transfer() { + // Case C: the NEW hash-only post-body gate accepts the matching hash + // regardless of expiry. A slow-transfer large upload survives. + let keys = Keys::generate(); + let body = b"hello world"; + let sha256 = hex::encode(sha2::Sha256::digest(body)); + let server = "relay.example"; + let past = Timestamp::now().as_secs().saturating_sub(10); + let exp_str = past.to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", server]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + // Hash-only post-body gate (new path): ignores freshness, accepts matching hash. + assert!( + verify_upload_hash_only(&event, &sha256).is_ok(), + "hash-only post-body gate must accept a matching hash regardless of expiry" + ); + } + + #[test] + fn test_upload_expiry_boundary_hash_mismatch_rejected() { + // Case D: verify_upload_hash_only rejects a wrong hash (security control). + let keys = Keys::generate(); + let body = b"hello world"; + let correct_sha256 = hex::encode(sha2::Sha256::digest(body)); + let wrong_sha256 = "a".repeat(64); + let server = "relay.example"; + let now = Timestamp::now().as_secs(); + let exp_str = (now + 60).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &correct_sha256]).unwrap(), + Tag::parse(["expiration", &exp_str]).unwrap(), + Tag::parse(["server", server]).unwrap(), + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + // Presenting the WRONG body hash — the proof is for a different blob. + assert!( + matches!( + verify_upload_hash_only(&event, &wrong_sha256), + Err(MediaError::HashMismatch) + ), + "hash-only gate must reject a body whose SHA-256 does not match the proof's x tag" + ); + } + + #[test] + fn test_permissive_valueless_expiration_then_valued_uses_last_wins() { + // FI-INV-15: Permissive mode must use last-wins expiration semantics + // (matching pre-NIP-FI base behavior). A valueless expiration tag + // followed by a future-valued expiration tag must admit, not expire. + let keys = Keys::generate(); + let sha256 = "c".repeat(64); + let now = Timestamp::now().as_secs(); + let exp_str = (now + 300).to_string(); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration"]).unwrap(), // valueless first + Tag::parse(["expiration", &exp_str]).unwrap(), // valued second + ]; + let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + assert!( + verify_blossom_upload_auth( + &event, + &sha256, + None, + BlossomStrictness::Permissive, + ) + .is_ok(), + "Permissive must use last-wins expiration: valueless-first must not cause expiry rejection" + ); + } } diff --git a/crates/buzz-media/src/error.rs b/crates/buzz-media/src/error.rs index 5abbea6f58..357920e640 100644 --- a/crates/buzz-media/src/error.rs +++ b/crates/buzz-media/src/error.rs @@ -3,6 +3,25 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; +/// Coarse Blossom denial kind for NIP-FI response shaping. +/// +/// Callers that know the active [`crate::auth::BlossomStrictness`] use this to +/// choose the correct HTTP response shape — NIP-FI fixed text/plain in Strict +/// mode, legacy JSON in Permissive mode — without requiring `buzz-media` to +/// depend on `buzz-auth`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BlossomDenialKind { + /// `Authorization` header was absent. Maps to HTTP 401 + `WWW-Authenticate: Nostr`. + MissingEvidence, + /// Authorization header or proof was present but structurally invalid, + /// malformed, expired, or otherwise rejected. Maps to HTTP 403. + EvidenceRejected, + /// A local-policy denial: relay membership required, community write fenced, + /// or similar authorization refusal that is not an auth-credential failure. + /// Maps to HTTP 403 with fixed `authorization denied\n` body [NIP-FI.md:764-775]. + AuthorizationDenied, +} + /// Errors from media operations. #[derive(Debug, thiserror::Error)] pub enum MediaError { @@ -26,6 +45,9 @@ pub enum MediaError { InvalidAuthVerb, #[error("missing required tag: {0}")] MissingTag(&'static str), + /// A tag that must appear exactly once appeared more than once. + #[error("duplicate tag: {0}")] + DuplicateTag(&'static str), #[error("hash mismatch")] HashMismatch, #[error("server mismatch")] @@ -109,6 +131,46 @@ impl From for MediaError { } } +impl MediaError { + /// Classify this error as a Blossom denial kind for response-shape selection. + /// + /// Returns `Some(BlossomDenialKind::MissingEvidence)` when the + /// `Authorization` header was absent, `Some(BlossomDenialKind::EvidenceRejected)` + /// for any structurally present but invalid/malformed/expired proof, + /// `Some(BlossomDenialKind::AuthorizationDenied)` for local-policy denials + /// (relay membership, community write fence), and `None` for non-auth errors. + /// + /// Relay call sites that know the active `BlossomStrictness` use this to + /// select the appropriate response shape: NIP-FI fixed text/plain in Strict + /// mode, legacy JSON 401 in Permissive mode. + pub fn blossom_denial_kind(&self) -> Option { + match self { + Self::MissingAuth => Some(BlossomDenialKind::MissingEvidence), + Self::InvalidAuthScheme + | Self::InvalidBase64 + | Self::InvalidAuthEvent + | Self::InvalidAuthKind + | Self::InvalidAuthVerb + | Self::DuplicateTag(_) + | Self::InvalidSignature + | Self::TokenExpired + | Self::TimestampOutOfWindow + | Self::Unauthorized + | Self::TokenRevoked + | Self::PubkeyMismatch + | Self::HashMismatch + | Self::ServerMismatch + | Self::MissingTag(_) => Some(BlossomDenialKind::EvidenceRejected), + // Local-policy denials: identity proved, but access refused by relay policy. + // Maps to AuthorizationDenied per NIP-FI.md:764-775. + Self::RelayMembershipRequired | Self::CommunityWriteFenced => { + Some(BlossomDenialKind::AuthorizationDenied) + } + _ => None, + } + } +} + impl IntoResponse for MediaError { fn into_response(self) -> Response { let (status, msg) = match &self { @@ -119,9 +181,15 @@ impl IntoResponse for MediaError { Self::FileTooLarge { .. } | Self::ImageTooLarge => { (StatusCode::PAYLOAD_TOO_LARGE, self.to_string()) } - // All authentication failures return the same generic 401 to prevent oracle enumeration. - // InsufficientScope is intentionally 403 — it's an authorization (not authentication) - // failure and is safe to distinguish since it requires a valid identity first. + // All authentication failures return the same generic 401 to prevent oracle + // enumeration in the legacy/Permissive path [FI-INV-15]. Off-mode deployments + // preserve this pre-NIP-FI behavior. InsufficientScope is intentionally 403 + // below — it is an authorization (not authentication) failure and is safe to + // distinguish because it requires a valid identity first. + // + // Strict mode routes through MediaDenial (buzz-relay) and applies the full + // NIP-FI rejection table (missing_evidence → 401, evidence_rejected → 403) + // independently of this path. Self::MissingAuth | Self::InvalidAuthScheme | Self::InvalidBase64 @@ -129,6 +197,7 @@ impl IntoResponse for MediaError { | Self::InvalidSignature | Self::InvalidAuthKind | Self::InvalidAuthVerb + | Self::DuplicateTag(_) | Self::TokenExpired | Self::TimestampOutOfWindow | Self::Unauthorized @@ -173,6 +242,136 @@ impl IntoResponse for MediaError { mod tests { use super::*; + // ── BlossomDenialKind classification ───────────────────────────────────── + + #[test] + fn missing_auth_is_missing_evidence() { + assert_eq!( + MediaError::MissingAuth.blossom_denial_kind(), + Some(BlossomDenialKind::MissingEvidence) + ); + } + + #[test] + fn structural_proof_errors_are_evidence_rejected() { + for error in [ + MediaError::InvalidAuthScheme, + MediaError::InvalidBase64, + MediaError::InvalidAuthEvent, + MediaError::InvalidAuthKind, + MediaError::InvalidAuthVerb, + MediaError::DuplicateTag("Authorization"), + MediaError::InvalidSignature, + MediaError::TokenExpired, + MediaError::TimestampOutOfWindow, + MediaError::Unauthorized, + MediaError::TokenRevoked, + MediaError::PubkeyMismatch, + MediaError::HashMismatch, + MediaError::ServerMismatch, + MediaError::MissingTag("t"), + ] { + assert_eq!( + error.blossom_denial_kind(), + Some(BlossomDenialKind::EvidenceRejected), + "expected EvidenceRejected for {error:?}" + ); + } + } + + #[test] + fn non_auth_errors_have_no_denial_kind() { + for error in [ + MediaError::NotFound, + MediaError::FileTooLarge { size: 1, max: 0 }, + MediaError::Internal, + MediaError::ServiceUnavailable, + MediaError::InsufficientScope, + ] { + assert_eq!( + error.blossom_denial_kind(), + None, + "expected None for {error:?}" + ); + } + } + + #[test] + fn local_policy_denials_are_authorization_denied() { + for error in [ + MediaError::RelayMembershipRequired, + MediaError::CommunityWriteFenced, + ] { + assert_eq!( + error.blossom_denial_kind(), + Some(BlossomDenialKind::AuthorizationDenied), + "expected AuthorizationDenied for {error:?}" + ); + } + } + + // ── IntoResponse status code pins ────────────────────────────────────── + // These pins cover the legacy/Permissive path (MediaError::into_response). + // Strict mode overrides this via MediaDenial in buzz-relay [FI-INV-15]. + + #[tokio::test] + async fn all_auth_failures_return_json_401_in_permissive_path() { + // In the legacy/Permissive path all auth failures collapse to a single + // JSON 401 to prevent oracle enumeration [FI-INV-15]. Strict mode + // (MediaDenial in buzz-relay) applies the NIP-FI 401/403 split instead. + for error in [ + MediaError::MissingAuth, + MediaError::InvalidAuthScheme, + MediaError::InvalidBase64, + MediaError::InvalidAuthEvent, + MediaError::InvalidAuthKind, + MediaError::InvalidAuthVerb, + MediaError::DuplicateTag("Authorization"), + MediaError::InvalidSignature, + MediaError::TokenExpired, + MediaError::TimestampOutOfWindow, + MediaError::Unauthorized, + MediaError::TokenRevoked, + MediaError::PubkeyMismatch, + MediaError::HashMismatch, + MediaError::ServerMismatch, + MediaError::MissingTag("t"), + ] { + let label = format!("{error:?}"); + let resp = error.into_response(); + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "Permissive path: expected 401 for {label}" + ); + let ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + ct.contains("application/json"), + "expected JSON CT for {label}, got: {ct}" + ); + // No WWW-Authenticate in the legacy JSON path. + assert!( + resp.headers().get("www-authenticate").is_none(), + "Permissive path must not include WWW-Authenticate for {label}" + ); + // Exact body: legacy path emits {"error":"authentication failed"} [FI-INV-15]. + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("body collect"); + assert_eq!( + body.as_ref(), + br#"{"error":"authentication failed"}"#, + "Permissive path: wrong body for {label}" + ); + } + } + + // ── Existing non-auth response map tests ──────────────────────────────── + #[test] fn serving_backend_failures_map_to_5xx_but_fences_remain_403() { for error in [ diff --git a/crates/buzz-media/src/lib.rs b/crates/buzz-media/src/lib.rs index 3198e1f830..81d341aa3a 100644 --- a/crates/buzz-media/src/lib.rs +++ b/crates/buzz-media/src/lib.rs @@ -19,7 +19,7 @@ pub use bucket_index::{ TaxonomySweepOutcome, }; pub use config::{MediaConfig, S3AddressingStyle}; -pub use error::MediaError; +pub use error::{BlossomDenialKind, MediaError}; pub use storage::{ BlobHeadMeta, BlobMeta, BulkDeleteOutcome, ByteStream, MediaStorage, ObjectVersionEntry, ObjectVersionKind, ObjectVersionRef, ObjectVersionsPage, diff --git a/crates/buzz-media/src/upload.rs b/crates/buzz-media/src/upload.rs index 524b033280..1b998bf843 100644 --- a/crates/buzz-media/src/upload.rs +++ b/crates/buzz-media/src/upload.rs @@ -5,7 +5,7 @@ use bytes::Bytes; use sha2::{Digest, Sha256}; use tokio::io::AsyncWriteExt; -use crate::auth::verify_blossom_upload_auth; +use crate::auth::verify_upload_hash_only; use crate::config::MediaConfig; use crate::error::MediaError; use crate::storage::{BlobMeta, MediaStorage}; @@ -74,15 +74,15 @@ where let auth = auth_event.clone(); let bytes = body.clone(); let cfg = config.clone(); - // Validate the Blossom `server` tag against the host this request was bound - // to (the per-request tenant), not a process-global domain — a relay serves - // many tenant hosts. - let bound_host = ctx.host().to_string(); let (mime, sha256, ext) = tokio::task::spawn_blocking(move || -> Result<_, MediaError> { let (mime, ext) = validate(&bytes, &cfg)?; let sha256 = hex::encode(Sha256::digest(&bytes)); - // Buffered uploads (image + file): 10-minute auth window is plenty. - verify_blossom_upload_auth(&auth, &sha256, Some(bound_host.as_str()), 600)?; + // Post-body hash check only: the full auth event verification + // (signature, kind, freshness, server, cardinality) was already + // applied at the pre-body gate in the relay handler. Re-running the + // full verifier here would fail any upload that takes longer than the + // minted token's expiration window (60 s in Strict mode). + verify_upload_hash_only(&auth, &sha256)?; Ok((mime, sha256, ext)) }) .await @@ -404,12 +404,13 @@ pub async fn process_video_upload( // --- 3. Verify Blossom auth: x tag must match computed SHA-256 --- let auth = auth_event.clone(); let sha256_for_auth = sha256_hex.clone(); - // Validate the Blossom `server` tag against the bound tenant host (not a - // process-global domain) — a relay serves many tenant hosts. - let bound_host = ctx.host().to_string(); tokio::task::spawn_blocking(move || { - // Videos: 1-hour window — large uploads on slow connections need headroom. - verify_blossom_upload_auth(&auth, &sha256_for_auth, Some(bound_host.as_str()), 3600) + // Post-body hash check only: the full auth event verification + // (signature, kind, freshness, server, cardinality) was already + // applied at the pre-body gate in the relay handler. Re-running the + // full verifier here would reject any video upload that takes longer + // than the minted token's expiration window (60 s in Strict mode). + verify_upload_hash_only(&auth, &sha256_for_auth) }) .await .map_err(|_| MediaError::Internal)??; @@ -731,3 +732,357 @@ mod tests { assert!(desc.duration.is_none()); } } + +// ── Upload pipeline expiry-boundary regression ────────────────────────────── +// +// Demonstrates that the production post-body check is `verify_upload_hash_only` +// (commit 75e9bef748d2149ce459b14da842e706a51a5f78), NOT the old full verifier +// `verify_blossom_upload_auth`. +// +// The critical sequence: +// T=0: proof is minted with `created_at = now - 58`, `expiration = now + 2`. +// Strict admission passes: age 58s ≤ 60s window, lifetime 60s, expiry future. +// T=3s: body transfer completes; post-body check runs on now-expired proof. +// Old path: verify_blossom_upload_auth → TokenExpired (breaks upload). +// New path: verify_upload_hash_only → Ok if hash matches. +// +// These tests exercise both `process_upload` (buffered, upload.rs:85) and +// `process_video_upload` (streaming, upload.rs:413) so that reverting either +// post-body call to the old full verifier breaks the positive case, and +// removing the hash check breaks the negative case. +// +// Note: `nostr::Timestamp::now()` reads the OS wall clock directly; paused +// Tokio time does not advance it. The positive cases use a real 3-second +// sleep to sequence fresh admission at T=0 followed by expired completion at +// T≈3s. The negative (hash-mismatch) cases use a pre-expired proof and need +// no timing sequence. +// +// Tests that require a live MinIO instance live in the `minio_tests` module so +// the nextest profile can tag them with `#[ignore = "requires MinIO"]` and the +// unit lane skips them automatically. +#[cfg(test)] +mod minio_tests { + use super::*; + use buzz_core::tenant::{CommunityId, TenantContext}; + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + use uuid::Uuid; + + /// Minimal valid 1×1 RGB PNG — passes validate_content and + /// validate_image_metadata_free without any metadata chunks. + /// + /// Generated once and embedded as const bytes to avoid runtime PNG + /// encoding in every test run. The exact pixels and structure are + /// irrelevant; only the magic bytes and structural validity matter. + const MINIMAL_PNG: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR length + type + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // width=1, height=1 + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, // bit-depth=8, color=RGB + 0xde, // IHDR CRC (partial — enough for magic-byte sniff) + 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, // IDAT length + type + 0x78, 0x9c, 0x63, 0xf8, 0xff, 0xff, 0x3f, 0x00, // zlib-compressed scanline + 0x05, 0xfe, 0x02, 0xfe, 0x0d, 0xef, 0x46, 0xb8, // (white pixel) + 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, // IEND type + 0xae, 0x42, 0x60, 0x82, // IEND CRC + ]; + + fn minio_config() -> MediaConfig { + MediaConfig { + s3_endpoint: "http://localhost:9000".to_string(), + s3_access_key: "buzz_dev".to_string(), + s3_secret_key: "buzz_dev_secret".to_string(), + s3_bucket: "buzz-media".to_string(), + s3_region: "us-east-1".to_string(), + s3_addressing_style: crate::config::S3AddressingStyle::Path, + max_image_bytes: 50 * 1024 * 1024, + max_gif_bytes: 10 * 1024 * 1024, + max_video_bytes: 524_288_000, + max_file_bytes: 104_857_600, + public_base_url: "http://localhost:9000/buzz-media".to_string(), + upload_records_enabled: false, + upload_ip_header: None, + upload_port_header: None, + } + } + + fn test_tenant() -> TenantContext { + TenantContext::resolved(CommunityId::from_uuid(Uuid::nil()), "relay.example") + } + + /// Build a fresh Blossom upload auth event that passes Strict admission NOW. + /// + /// Strict invariants: + /// - `created_at` ≤ now + 5s (future-skew) + /// - now - `created_at` ≤ 60s (replay window) + /// - `expiration` ≤ `created_at` + 60s (token lifetime) + /// - `expiration` > now (not yet expired) + /// + /// We set `created_at = now - 58` (age = 58s, inside the 60s window), + /// `expiration = now - 58 + 60 = now + 2` (lifetime = 60s, strictly future). + /// Calling `verify_blossom_upload_auth(Strict)` at sign time must return `Ok`. + /// After a ≥3 s real sleep the proof is expired (expiration ≤ now): the old + /// full verifier would return `TokenExpired`, while `verify_upload_hash_only` + /// only checks the `x` tag and must still succeed. + fn fresh_strict_upload_auth(keys: &Keys, sha256: &str, server: &str) -> nostr::Event { + let now = Timestamp::now().as_secs(); + let created_at = now.saturating_sub(58); + let exp = created_at + 60; // now + 2s: strictly future, lifetime = 60s + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", sha256]).unwrap(), + Tag::parse(["expiration", &exp.to_string()]).unwrap(), + Tag::parse(["server", server]).unwrap(), + ]; + EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .custom_created_at(Timestamp::from_secs(created_at)) + .sign_with_keys(keys) + .expect("sign fresh upload auth") + } + + /// Build an already-expired Blossom upload auth event for the mismatch-hash + /// negative cases. No admission path is needed there: we only exercise the + /// post-body hash check, so the proof need not be fresh. + /// + /// `created_at = now - 120`, `expiration = now - 1`: definitively past for + /// the old full verifier (`TokenExpired`); the `x` tag is the only thing + /// `verify_upload_hash_only` reads. + fn expired_upload_auth(keys: &Keys, sha256: &str, server: &str) -> nostr::Event { + let now = Timestamp::now().as_secs(); + let created_at = now.saturating_sub(120); + let exp = now.saturating_sub(1); + let tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["x", sha256]).unwrap(), + Tag::parse(["expiration", &exp.to_string()]).unwrap(), + Tag::parse(["server", server]).unwrap(), + ]; + EventBuilder::new(Kind::from(24242), "Upload buzz-media") + .tags(tags) + .custom_created_at(Timestamp::from_secs(created_at)) + .sign_with_keys(keys) + .expect("sign expired upload auth") + } + + /// Case B (buffered pipeline): the NEW post-body path (`verify_upload_hash_only`) + /// accepts a proof whose expiry passes during the transfer. + /// + /// Sequence: + /// T=0: `fresh_strict_upload_auth` signs a 60s-lifetime proof that passes + /// Strict admission (`verify_blossom_upload_auth(Strict)` returns `Ok`). + /// T≈3s: real sleep lets the proof expire (expiration ≤ now). + /// T≈3s: `process_upload` runs; its post-body call is `verify_upload_hash_only`. + /// Old path: `verify_blossom_upload_auth` → `TokenExpired` (upload breaks). + /// New path: `verify_upload_hash_only` → `Ok` if hash matches. + /// + /// Note: `nostr::Timestamp::now()` reads the wall clock, not Tokio time. + /// Paused Tokio time does not advance it; a real sleep is required. + /// + /// Discriminating mutation: replacing `verify_upload_hash_only` at `upload.rs:85` + /// with `verify_blossom_upload_auth` causes this test to fail with `TokenExpired`. + /// + /// Requires a live MinIO instance (endpoint http://localhost:9000). + #[tokio::test] + #[ignore = "requires MinIO"] + async fn buffered_upload_accepts_expired_proof_when_hash_matches() { + use crate::auth::{verify_blossom_upload_auth, BlossomStrictness}; + + let keys = Keys::generate(); + let body = Bytes::from_static(MINIMAL_PNG); + let sha256 = hex::encode(sha2::Sha256::digest(&body)); + let auth = fresh_strict_upload_auth(&keys, &sha256, "relay.example"); + + // Assert that the proof passes Strict admission RIGHT NOW, before any sleep. + assert!( + verify_blossom_upload_auth( + &auth, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ) + .is_ok(), + "proof must pass Strict admission at sign time" + ); + + // Wait for the proof to expire. The expiry is `now + 2s` at sign time; + // 3s guarantees expiration ≤ current time when process_upload runs. + // `nostr::Timestamp::now()` reads the OS wall clock directly. + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + + let storage = MediaStorage::new(&minio_config()).expect("MinIO client must initialise"); + let ctx = test_tenant(); + + // process_upload calls verify_upload_hash_only post-body (the repaired path). + // The proof is now expired; a matching hash must still be accepted. + let result = process_upload( + &storage, + &minio_config(), + &ctx, + &auth, + body, + None, // no attribution + ) + .await; + + assert!( + result.is_ok(), + "buffered upload must accept an expired proof with a matching hash \ + (verify_upload_hash_only); got: {:?}", + result.err() + ); + } + + /// Case D (buffered pipeline): `verify_upload_hash_only` rejects a mismatched + /// body hash even when the proof is otherwise structurally valid. + /// + /// Discriminating mutation: removing the `verify_upload_hash_only` call + /// (or replacing it with a no-op) causes this test to accept a mismatched + /// body, which is a security regression — the relay would store whatever + /// bytes the client sent without verifying the Blossom `x` tag. + /// + /// Requires a live MinIO instance (endpoint http://localhost:9000). + #[tokio::test] + #[ignore = "requires MinIO"] + async fn buffered_upload_rejects_mismatched_hash_through_production_path() { + let keys = Keys::generate(); + // Sign the auth for a DIFFERENT hash than the actual body. + let mismatched_sha256 = "a".repeat(64); + let auth = expired_upload_auth(&keys, &mismatched_sha256, "relay.example"); + + // The body has a hash that differs from what was signed in the auth event. + let body = Bytes::from_static(MINIMAL_PNG); + + let storage = MediaStorage::new(&minio_config()).expect("MinIO client must initialise"); + let ctx = test_tenant(); + + let result = process_upload(&storage, &minio_config(), &ctx, &auth, body, None).await; + + assert!( + matches!(result, Err(MediaError::HashMismatch)), + "buffered upload must reject a body whose SHA-256 does not match \ + the signed x tag; got: {:?}", + result + ); + } + + /// Case B (streaming pipeline): `process_video_upload` accepts a proof whose + /// expiry passes during the transfer. + /// + /// Sequence mirrors the buffered case: + /// T=0: `fresh_strict_upload_auth` signs a 60s-lifetime proof that passes + /// Strict admission. + /// T≈3s: real sleep lets the proof expire. + /// T≈3s: `process_video_upload` streams to disk, computes SHA-256, then calls + /// `verify_upload_hash_only` (the repaired path). + /// Old path: `verify_blossom_upload_auth` → `TokenExpired`. + /// New path: `verify_upload_hash_only` → `Ok` if hash matches. + /// + /// Discriminating mutation: replacing `verify_upload_hash_only` at `upload.rs:413` + /// with `verify_blossom_upload_auth` causes this test to fail with `TokenExpired`. + /// + /// Requires a live MinIO instance (endpoint http://localhost:9000). + #[tokio::test] + #[ignore = "requires MinIO"] + async fn streaming_upload_accepts_expired_proof_when_hash_matches() { + use crate::auth::{verify_blossom_upload_auth, BlossomStrictness}; + use crate::validation::minimal_valid_mp4; + use futures_util::stream; + + let keys = Keys::generate(); + let body_bytes = minimal_valid_mp4(); + let sha256 = hex::encode(sha2::Sha256::digest(&body_bytes)); + let auth = fresh_strict_upload_auth(&keys, &sha256, "relay.example"); + + // Assert Strict admission passes at sign time. + assert!( + verify_blossom_upload_auth( + &auth, + &sha256, + Some("relay.example"), + BlossomStrictness::Strict + ) + .is_ok(), + "proof must pass Strict admission at sign time" + ); + + // Wait for the proof to expire (3s > 2s remaining until expiry). + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + + let body_len = body_bytes.len() as u64; + // Wrap the bytes in a single-item stream of Ok(Bytes). + // axum::Error wraps std::io::Error; we never inject an error here. + let body_stream = + stream::once(async move { Ok::<_, axum::Error>(Bytes::from(body_bytes)) }); + + let storage = MediaStorage::new(&minio_config()).expect("MinIO client must initialise"); + let ctx = test_tenant(); + + // process_video_upload streams to disk, computes SHA-256, then calls + // verify_upload_hash_only (the repaired path). The proof is now expired; + // a matching hash must still be accepted. + let result = process_video_upload( + &storage, + &minio_config(), + &ctx, + &auth, + body_stream, + Some(body_len), + None, // no attribution + ) + .await; + + assert!( + result.is_ok(), + "streaming upload must accept an expired proof with a matching hash \ + (verify_upload_hash_only); got: {:?}", + result.err() + ); + } + + /// Case D (streaming pipeline): `verify_upload_hash_only` rejects a mismatched + /// body hash through the streaming completion path. + /// + /// Discriminating mutation: removing the `verify_upload_hash_only` call at + /// `upload.rs:413` causes this test to accept a mismatched body, which is a + /// security regression — the relay would store whatever bytes the client sent + /// without verifying the Blossom `x` tag. + /// + /// Requires a live MinIO instance (endpoint http://localhost:9000). + #[tokio::test] + #[ignore = "requires MinIO"] + async fn streaming_upload_rejects_mismatched_hash_through_production_path() { + use crate::validation::minimal_valid_mp4; + use futures_util::stream; + + let keys = Keys::generate(); + // Sign auth for a DIFFERENT hash than the actual body. + let mismatched_sha256 = "a".repeat(64); + let auth = expired_upload_auth(&keys, &mismatched_sha256, "relay.example"); + + let body_bytes = minimal_valid_mp4(); + let body_len = body_bytes.len() as u64; + let body_stream = + stream::once(async move { Ok::<_, axum::Error>(Bytes::from(body_bytes)) }); + + let storage = MediaStorage::new(&minio_config()).expect("MinIO client must initialise"); + let ctx = test_tenant(); + + let result = process_video_upload( + &storage, + &minio_config(), + &ctx, + &auth, + body_stream, + Some(body_len), + None, + ) + .await; + + assert!( + matches!(result, Err(MediaError::HashMismatch)), + "streaming upload must reject a body whose SHA-256 does not match \ + the signed x tag; got: {:?}", + result + ); + } +} diff --git a/crates/buzz-media/src/validation.rs b/crates/buzz-media/src/validation.rs index 706c354d04..980e28d8cb 100644 --- a/crates/buzz-media/src/validation.rs +++ b/crates/buzz-media/src/validation.rs @@ -954,6 +954,65 @@ pub fn mime_to_ext(mime: &str) -> &'static str { } } +/// Return a minimal structurally-valid H.264/avc1 MP4 body (618 bytes). +/// +/// Fast-start layout (ftyp + moov + mdat), single video track, 1 s, 320×240, +/// no audio. Generated from the same builder as the unit tests and verified to +/// pass `validate_video_file` with the default `MediaConfig`. +/// +/// Exposed only under `#[cfg(test)]` so it has no runtime cost; placed at +/// module scope (not inside `mod tests`) so other test modules in this crate +/// can import it via `use crate::validation::minimal_valid_mp4;`. +#[cfg(test)] +pub(crate) fn minimal_valid_mp4() -> Vec { + // ftyp(isom) + moov + mdat — pre-computed; identical to + // build_mp4_bytes(true, b"avc1", 1_000, 320, 240, false). + vec![ + 0x00, 0x00, 0x00, 0x14, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d, 0x00, 0x00, 0x00, + 0x00, 0x69, 0x73, 0x6f, 0x6d, 0x00, 0x00, 0x02, 0x4e, 0x6d, 0x6f, 0x6f, 0x76, 0x00, 0x00, + 0x00, 0x6c, 0x6d, 0x76, 0x68, 0x64, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xe8, 0x00, 0x00, 0x03, 0xe8, 0x00, 0x01, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x02, 0x00, 0x00, 0x01, 0xda, 0x74, 0x72, 0x61, 0x6b, 0x00, 0x00, 0x00, 0x5c, 0x74, 0x6b, + 0x68, 0x64, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xe8, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, + 0x00, 0x00, 0x00, 0x01, 0x40, 0x00, 0x00, 0x00, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x01, 0x76, + 0x6d, 0x64, 0x69, 0x61, 0x00, 0x00, 0x00, 0x20, 0x6d, 0x64, 0x68, 0x64, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xe8, 0x00, 0x00, + 0x03, 0xe8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2d, 0x68, 0x64, 0x6c, 0x72, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x76, 0x69, 0x64, 0x65, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x48, 0x61, + 0x6e, 0x64, 0x6c, 0x65, 0x72, 0x00, 0x00, 0x00, 0x01, 0x21, 0x6d, 0x69, 0x6e, 0x66, 0x00, + 0x00, 0x00, 0x14, 0x76, 0x6d, 0x68, 0x64, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x24, 0x64, 0x69, 0x6e, 0x66, 0x00, 0x00, 0x00, + 0x1c, 0x64, 0x72, 0x65, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x0c, 0x75, 0x72, 0x6c, 0x20, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0xe1, 0x73, + 0x74, 0x62, 0x6c, 0x00, 0x00, 0x00, 0x79, 0x73, 0x74, 0x73, 0x64, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x69, 0x61, 0x76, 0x63, 0x31, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x40, 0x00, 0xf0, 0x00, 0x48, 0x00, 0x00, 0x00, + 0x48, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0xff, 0xff, + 0x00, 0x00, 0x00, 0x13, 0x61, 0x76, 0x63, 0x43, 0x01, 0x42, 0x00, 0x1e, 0xff, 0xe1, 0x00, + 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x73, 0x74, 0x74, 0x73, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x03, 0xe8, 0x00, 0x00, + 0x00, 0x1c, 0x73, 0x74, 0x73, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x18, + 0x73, 0x74, 0x73, 0x7a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x14, 0x73, 0x74, 0x63, 0x6f, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x1c, 0x00, 0x00, 0x00, 0x08, 0x6d, + 0x64, 0x61, 0x74, + ] +} + #[cfg(test)] mod tests { use super::*; @@ -2324,6 +2383,21 @@ mod tests { } } + /// Smoke-test: `minimal_valid_mp4()` (the bytes embedded for streaming upload + /// tests) must round-trip through `validate_video_file`. If this fails, the + /// embedded byte sequence needs to be re-generated. + #[test] + fn test_minimal_valid_mp4_passes_validate_video_file() { + let mp4_bytes = minimal_valid_mp4(); + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), &mp4_bytes).unwrap(); + let result = validate_video_file(tmp.path(), &test_config()); + assert!( + result.is_ok(), + "minimal_valid_mp4() must pass validate_video_file; got: {result:?}" + ); + } + #[test] fn test_validate_video_accepts_proprietary_major_with_isom_compatibility() { let mut mp4_bytes = build_minimal_mp4_moov_first(); diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index 62e5945b3d..7c56d557c7 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -19,8 +19,12 @@ use axum::{ }; use base64::Engine; use buzz_audit::{AuditAction, NewAuditEntry}; +use buzz_auth::DenialClass; use buzz_core::tenant::TenantContext; -use buzz_media::{BlobDescriptor, MediaError, UploadAttribution, UploadNetworkInfo}; +use buzz_media::auth::BlossomStrictness; +use buzz_media::{ + BlobDescriptor, BlossomDenialKind, MediaError, UploadAttribution, UploadNetworkInfo, +}; use crate::state::AppState; @@ -70,6 +74,10 @@ pub(crate) struct AuthenticatedUpload { /// door in `bridge.rs`. Server-resolved, never client-supplied. tenant: TenantContext, route_mode: UploadRouteMode, + /// NIP-FI strictness derived at extraction time. Carried to the handler so + /// post-body auth failures (hash-binding, x-tag mismatches) produce the same + /// mode-aware denial shape as pre-body failures. + strictness: BlossomStrictness, _upload_permit: UploadPermit, } @@ -79,6 +87,63 @@ enum UploadRouteMode { LegacyMedia, } +/// Mode-aware Blossom auth rejection for the relay layer. +/// +/// In `Strict` mode, Blossom denial errors map to the NIP-FI fixed +/// text/plain responses (`DenialClass` byte contract). In `Permissive` mode +/// (Off-mode deployments), the legacy JSON 401 shape is preserved unchanged +/// [FI-INV-15]. +/// +/// Non-Blossom errors (`blossom_denial_kind()` returns `None`) always fall +/// through to `MediaError::into_response()` regardless of mode. +pub(crate) struct MediaDenial(MediaError, BlossomStrictness); + +impl IntoResponse for MediaDenial { + fn into_response(self) -> Response { + let MediaDenial(error, strictness) = self; + if strictness == BlossomStrictness::Strict { + if let Some(kind) = error.blossom_denial_kind() { + let class = match kind { + BlossomDenialKind::MissingEvidence => DenialClass::MissingEvidence, + BlossomDenialKind::EvidenceRejected => DenialClass::EvidenceRejected, + BlossomDenialKind::AuthorizationDenied => DenialClass::AuthorizationDenied, + }; + tracing::warn!( + error = %error, + denial_class = ?class, + "Blossom auth denial (strict)" + ); + let mut builder = axum::http::Response::builder() + .status(class.http_status()) + .header(header::CONTENT_TYPE, class.content_type()); + if let Some(challenge) = class.www_authenticate() { + builder = builder.header("WWW-Authenticate", challenge); + } + return builder + .body(axum::body::Body::from(class.http_body())) + .expect("NIP-FI denial response is always valid"); + } + } + error.into_response() + } +} + +/// Wrap a `MediaError` with the active strictness to produce the correct +/// response shape at the relay boundary. +fn media_denial(error: MediaError, strictness: BlossomStrictness) -> MediaDenial { + MediaDenial(error, strictness) +} + +impl From for MediaDenial { + /// Default conversion uses Permissive mode — non-auth errors always fall + /// through to `MediaError::into_response()` regardless of mode, so the + /// strictness value is irrelevant. Auth errors at the extractor boundary + /// use explicit `media_denial(e, strictness)` calls instead. + fn from(e: MediaError) -> Self { + MediaDenial(e, BlossomStrictness::Permissive) + } +} + fn should_stream_as_video(sniff: &[u8]) -> bool { infer::get(sniff).is_some_and(|kind| kind.mime_type() == "video/mp4") || buzz_media::looks_like_iso_bmff(sniff) @@ -240,12 +305,8 @@ fn serving_lease_lost(error: anyhow::Error) -> MediaError { /// Returns a [`BlobDescriptor`] JSON on success. // TODO(v2): Add persistent per-pubkey storage quotas. Admission limits below // bound active parser/storage work, but they do not cap durable bytes stored. -// UploadContext is pub(crate) — it's an internal extractor type, never exposed -// outside this crate. The warning is benign: axum resolves it at compile time -// via trait bounds, not by name. -#[allow(private_interfaces)] #[allow(clippy::result_large_err)] // Response is the natural error type for axum closures -pub async fn upload_blob( +pub(crate) async fn upload_blob( State(state): State>, ctx: UploadContext, headers: HeaderMap, @@ -261,12 +322,14 @@ pub async fn upload_blob( // The closure must verify the auth event against the tenant host BEFORE // returning the proven pubkey to the admission gate — same ordering invariant // as the read path. [FI-TRACE-AUTHORITY-UNIFORM] + let strictness = blossom_strictness_from_state(&state); let tenant_host = ctx.tenant.host().to_owned(); let headers_clone = headers.clone(); let admission = match admit_nip_fi_http_on_state(&state, &headers, move || { let auth_event = extract_blossom_auth(&headers_clone).map_err(|e| e.into_response())?; - // Permissive window (3600s): content type unknown until body arrives. - buzz_media::auth::verify_blossom_auth_event(&auth_event, Some(&tenant_host), 3600) + // Pre-body check: freshness, cardinality, and server tag. The x-tag + // hash binding is checked after body completion. + buzz_media::auth::verify_blossom_auth_event(&auth_event, Some(&tenant_host), strictness) .map_err(|e| e.into_response())?; let pubkey = auth_event.pubkey; Ok(Nip98Proof::new(pubkey, auth_event)) @@ -281,21 +344,23 @@ pub async fn upload_blob( // them because they protect body integrity, not the assertion boundary. let claimed_hash = match headers.get("x-sha-256").and_then(|v| v.to_str().ok()) { Some(h) => h.to_owned(), - None => return MediaError::MissingTag("x-sha-256").into_response(), + None => { + return media_denial(MediaError::MissingTag("x-sha-256"), strictness).into_response() + } }; if claimed_hash.len() != 64 || !claimed_hash .chars() .all(|c| matches!(c, '0'..='9' | 'a'..='f')) { - return MediaError::HashMismatch.into_response(); + return media_denial(MediaError::HashMismatch, strictness).into_response(); } let has_matching_x = auth_event .tags .iter() .any(|tag| tag.kind().to_string() == "x" && (tag.content() == Some(&claimed_hash))); if !has_matching_x { - return MediaError::HashMismatch.into_response(); + return media_denial(MediaError::HashMismatch, strictness).into_response(); } // Post-admission: relay membership gate (NIP-43). @@ -309,7 +374,7 @@ pub async fn upload_blob( ) .await .map(|_| ()) - .map_err(|_| MediaError::RelayMembershipRequired) + .map_err(|_| media_denial(MediaError::RelayMembershipRequired, strictness)) { return e.into_response(); } @@ -337,6 +402,7 @@ pub async fn upload_blob( auth_event, tenant: ctx.tenant, route_mode: ctx.route_mode, + strictness, _upload_permit: upload_permit, }; upload_blob_inner(state, auth, headers, body).await @@ -359,13 +425,15 @@ async fn upload_blob_result( auth: AuthenticatedUpload, headers: HeaderMap, body: axum::body::Body, -) -> Result, MediaError> { +) -> Result, MediaDenial> { + let strictness = auth.strictness; let attribution = upload_attribution(&state, &auth, &headers).await; let serving_write = buzz_deletion::acquire_serving_write(&state.db, auth.tenant.community(), "media_upload") .await - .map_err(serving_write_error)?; + .map_err(serving_write_error) + .map_err(|e| media_denial(e, strictness))?; if auth.route_mode == UploadRouteMode::LegacyMedia { metrics::counter!("buzz_media_legacy_upload_route_total").increment(1); @@ -386,13 +454,19 @@ async fn upload_blob_result( sniff.extend_from_slice(&chunk[..chunk.len().min(needed)]); replay_chunks.push(chunk); } - Some(Err(error)) => return Err(MediaError::Io(error.to_string())), + Some(Err(error)) => { + return Err(media_denial(MediaError::Io(error.to_string()), strictness)) + } None => break, } } let replay = futures_util::stream::iter(replay_chunks.into_iter().map(Ok)).chain(source); - serving_write.verify().await.map_err(serving_lease_lost)?; + serving_write + .verify() + .await + .map_err(serving_lease_lost) + .map_err(MediaDenial::from)?; let mut descriptor = serving_write .protect(async { @@ -471,7 +545,15 @@ async fn upload_blob_result( Err(_) => MediaError::Internal, } } - })??; + }) + // The outer anyhow→MediaError map above captures lease-loss and + // protect-layer failures. Apply media_denial so Strict produces + // byte-exact NIP-FI responses for those errors. + .map_err(|e| media_denial(e, strictness))? + // The inner Result captures failures from the async body (process_*, + // hash mismatches). Wrap through media_denial so post-body auth errors + // get the same NIP-FI shape as pre-body ones. + .map_err(|e| media_denial(e, strictness))?; rewrite_descriptor_urls_for_tenant( &mut descriptor, @@ -577,9 +659,10 @@ fn extract_blossom_read_proof( headers: &HeaderMap, sha256: &str, tenant_host: &str, + strictness: BlossomStrictness, ) -> Result, MediaError> { let auth_event = extract_blossom_auth(headers)?; - buzz_media::auth::verify_blossom_get_auth(&auth_event, sha256, Some(tenant_host), 3600)?; + buzz_media::auth::verify_blossom_get_auth(&auth_event, sha256, Some(tenant_host), strictness)?; let pubkey = auth_event.pubkey; Ok(crate::nip_fi_http::Nip98Proof::new(pubkey, auth_event)) } @@ -595,7 +678,8 @@ async fn enforce_blossom_read_membership( tenant: &TenantContext, auth_event: &nostr::Event, headers: &HeaderMap, -) -> Result<(), MediaError> { + strictness: BlossomStrictness, +) -> Result<(), MediaDenial> { let auth_tag = crate::api::relay_members::extract_auth_tag_header(headers); crate::api::relay_members::enforce_relay_membership( state, @@ -606,7 +690,7 @@ async fn enforce_blossom_read_membership( ) .await .map(|_| ()) - .map_err(|_| MediaError::RelayMembershipRequired) + .map_err(|_| media_denial(MediaError::RelayMembershipRequired, strictness)) } fn blob_cache_control() -> &'static str { @@ -693,11 +777,11 @@ const MAX_RANGE_CHUNK: u64 = 16 * 1024 * 1024; /// /// All responses include `Accept-Ranges: bytes` so video players know seeking is supported. #[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers -pub async fn get_blob( +pub(crate) async fn get_blob( State(state): State>, Path(sha256_ext): Path, req_headers: HeaderMap, -) -> Result { +) -> Result { validate_media_path(&sha256_ext)?; // Row zero: bind tenant. Blossom auth extraction and NIP-FI admission follow // so that in Enforce mode a missing/malformed Authorization header produces @@ -708,6 +792,7 @@ pub async fn get_blob( .next() .unwrap_or(&sha256_ext) .to_owned(); + let strictness = blossom_strictness_from_state(&state); let tenant_host = tenant.host().to_owned(); let headers_clone = req_headers.clone(); // NIP-FI admission with Blossom extraction as the NIP-98 closure. @@ -715,7 +800,7 @@ pub async fn get_blob( // EvidenceRejected). In Off mode: MediaError propagates unchanged [FI-INV-15]. use crate::nip_fi_http::admit_nip_fi_http_on_state; let admission = match admit_nip_fi_http_on_state(&state, &req_headers, move || { - extract_blossom_read_proof(&headers_clone, &sha256, &tenant_host) + extract_blossom_read_proof(&headers_clone, &sha256, &tenant_host, strictness) .map_err(|e| e.into_response()) }) { Ok(a) => a, @@ -723,8 +808,10 @@ pub async fn get_blob( }; let auth_event = admission.into_extra(); // Post-admission: membership gate. - enforce_blossom_read_membership(&state, &tenant, &auth_event, &req_headers).await?; - serve_blob_for_tenant(&state, &tenant, &sha256_ext, &req_headers).await + enforce_blossom_read_membership(&state, &tenant, &auth_event, &req_headers, strictness).await?; + serve_blob_for_tenant(&state, &tenant, &sha256_ext, &req_headers) + .await + .map_err(MediaDenial::from) } /// Serve a validated blob from an already-authorized tenant context. @@ -983,11 +1070,11 @@ fn parse_byte_range(range: &str, total: u64) -> Option<(u64, u64)> { /// object metadata — to prevent MIME spoofing via tampered storage. If the sidecar /// is missing, we return 404 rather than fall back to untrusted metadata. #[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers -pub async fn head_blob( +pub(crate) async fn head_blob( State(state): State>, headers: HeaderMap, Path(sha256_ext): Path, -) -> Result { +) -> Result { validate_media_path(&sha256_ext)?; // Row zero: bind tenant. Blossom auth extraction and NIP-FI admission follow // so that in Enforce mode a missing/malformed Authorization header produces @@ -998,18 +1085,19 @@ pub async fn head_blob( .next() .unwrap_or(&sha256_ext) .to_owned(); + let strictness = blossom_strictness_from_state(&state); let tenant_host = tenant.host().to_owned(); let headers_clone = headers.clone(); use crate::nip_fi_http::admit_nip_fi_http_on_state; let admission = match admit_nip_fi_http_on_state(&state, &headers, move || { - extract_blossom_read_proof(&headers_clone, &sha256, &tenant_host) + extract_blossom_read_proof(&headers_clone, &sha256, &tenant_host, strictness) .map_err(|e| e.into_response()) }) { Ok(a) => a, Err(resp) => return Ok(resp), }; let auth_event = admission.into_extra(); - enforce_blossom_read_membership(&state, &tenant, &auth_event, &headers).await?; + enforce_blossom_read_membership(&state, &tenant, &auth_event, &headers, strictness).await?; let cache_control = blob_cache_control(); // Sidecar gate FIRST — reject before any blob I/O. @@ -1035,7 +1123,7 @@ pub async fn head_blob( .await .map_err(|_| MediaError::NotFound)?; if requested_ext != sidecar.ext { - return Err(MediaError::NotFound); + return Err(MediaError::NotFound.into()); } } sidecar_mime @@ -1090,6 +1178,10 @@ async fn resolve_s3_key( /// Extract and verify a kind:24242 Blossom auth event from the `Authorization` header. /// /// Accepts both base64url (BUD-11 spec) and standard base64 (nostr-tools compat). +/// +/// Takes the first `Authorization` value. Repeated `Authorization` fields are +/// rejected in Enforce by the cardinality gate in `admit_nip_fi_http`; Off keeps +/// the legacy first-value behavior [FI-INV-15]. fn extract_blossom_auth(headers: &HeaderMap) -> Result { use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; @@ -1113,13 +1205,26 @@ fn extract_blossom_auth(headers: &HeaderMap) -> Result Ok(event) } +/// Derive `BlossomStrictness` from the relay's NIP-FI mode. +/// +/// Enforce applies the strict NIP-FI kind-24242 rules. Off keeps the +/// pre-NIP-FI permissive verifier [FI-INV-15]. DenyProtected never reaches a +/// verifier: `admit_nip_fi_http_on_state` answers 503 first. +fn blossom_strictness_from_state(state: &AppState) -> BlossomStrictness { + if state.config.nip_fi.is_enforce() { + BlossomStrictness::Strict + } else { + BlossomStrictness::Permissive + } +} + #[cfg(test)] mod tests { use super::*; use std::sync::Arc; use axum::{ - body::Body, + body::{to_bytes, Body}, http::{header, Request, StatusCode}, }; use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; @@ -1128,6 +1233,251 @@ mod tests { const VALID_HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + // ── MediaDenial response-shape tests (NIP-FI §755-773) ────────────────── + // These tests pin the byte-exact response contract for Strict mode and + // confirm Permissive mode produces the unchanged legacy JSON 401 shape. + + #[tokio::test] + async fn strict_missing_auth_produces_nip_fi_401_with_www_authenticate() { + let denial = MediaDenial(MediaError::MissingAuth, BlossomStrictness::Strict); + let resp = denial.into_response(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + + let ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + ct.contains("text/plain"), + "expected text/plain CT, got: {ct}" + ); + + let www_auth = resp + .headers() + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + www_auth, "Nostr", + "expected 'Nostr' WWW-Authenticate challenge" + ); + + let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + assert_eq!(body.as_ref(), b"authentication required\n"); + } + + #[tokio::test] + async fn strict_evidence_rejected_produces_nip_fi_403_text_plain() { + for error in [ + MediaError::InvalidSignature, + MediaError::TokenExpired, + MediaError::TimestampOutOfWindow, + MediaError::HashMismatch, + MediaError::ServerMismatch, + MediaError::MissingTag("server"), + MediaError::DuplicateTag("Authorization"), + MediaError::InvalidAuthScheme, + ] { + let label = format!("{error:?}"); + let denial = MediaDenial(error, BlossomStrictness::Strict); + let resp = denial.into_response(); + + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "expected 403 for Strict {label}" + ); + + let ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + ct.contains("text/plain"), + "expected text/plain CT for {label}, got: {ct}" + ); + + assert!( + resp.headers().get("www-authenticate").is_none(), + "403 must not have WWW-Authenticate for {label}" + ); + + let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + body.as_ref(), + b"evidence rejected\n", + "wrong body for {label}" + ); + } + } + + #[tokio::test] + async fn permissive_missing_auth_keeps_legacy_json_401() { + let denial = MediaDenial(MediaError::MissingAuth, BlossomStrictness::Permissive); + let resp = denial.into_response(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + + let ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + ct.contains("application/json"), + "Permissive must keep JSON CT, got: {ct}" + ); + + assert!( + resp.headers().get("www-authenticate").is_none(), + "Permissive must not add WWW-Authenticate" + ); + } + + #[tokio::test] + async fn permissive_evidence_rejected_keeps_legacy_json_401() { + for error in [ + MediaError::InvalidSignature, + MediaError::TokenExpired, + MediaError::HashMismatch, + MediaError::MissingTag("t"), + ] { + let label = format!("{error:?}"); + let denial = MediaDenial(error, BlossomStrictness::Permissive); + let resp = denial.into_response(); + + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "Permissive: expected 401 for {label}" + ); + + let ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + ct.contains("application/json"), + "Permissive must keep JSON CT for {label}, got: {ct}" + ); + } + } + + #[tokio::test] + async fn non_auth_errors_always_fall_through_regardless_of_mode() { + for strictness in [BlossomStrictness::Strict, BlossomStrictness::Permissive] { + let denial = MediaDenial(MediaError::NotFound, strictness); + assert_eq!(denial.into_response().status(), StatusCode::NOT_FOUND); + + let denial = MediaDenial(MediaError::Internal, strictness); + assert!(denial.into_response().status().is_server_error()); + } + } + + // ── Extractor hash-check denial sites (sites 1+2 per Paul's spot-check) ── + // These pins cover the missing-X-SHA-256 header (MissingTag) and the + // malformed/unmatched hash (HashMismatch) cases that previously bypassed + // the strictness split via `From for MediaDenial` (Permissive). + + #[tokio::test] + async fn strict_missing_x_sha256_header_produces_nip_fi_403() { + let denial = MediaDenial( + MediaError::MissingTag("x-sha-256"), + BlossomStrictness::Strict, + ); + let resp = denial.into_response(); + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "missing x-sha-256 header must be 403 in Strict mode" + ); + let ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + ct.contains("text/plain"), + "expected text/plain CT, got: {ct}" + ); + assert!( + resp.headers().get("www-authenticate").is_none(), + "403 must not have WWW-Authenticate" + ); + let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + assert_eq!(body.as_ref(), b"evidence rejected\n"); + } + + #[tokio::test] + async fn strict_hash_mismatch_produces_nip_fi_403() { + let denial = MediaDenial(MediaError::HashMismatch, BlossomStrictness::Strict); + let resp = denial.into_response(); + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "hash mismatch must be 403 in Strict mode" + ); + let ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!(ct.contains("text/plain"), "expected text/plain, got: {ct}"); + let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + assert_eq!(body.as_ref(), b"evidence rejected\n"); + } + + #[tokio::test] + async fn permissive_missing_x_sha256_header_keeps_legacy_json_401() { + let denial = MediaDenial( + MediaError::MissingTag("x-sha-256"), + BlossomStrictness::Permissive, + ); + let resp = denial.into_response(); + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "Permissive must keep legacy 401 for MissingTag" + ); + let ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + ct.contains("application/json"), + "Permissive must keep JSON CT, got: {ct}" + ); + assert!( + resp.headers().get("www-authenticate").is_none(), + "Permissive must not add WWW-Authenticate" + ); + } + + #[tokio::test] + async fn permissive_hash_mismatch_keeps_legacy_json_401() { + let denial = MediaDenial(MediaError::HashMismatch, BlossomStrictness::Permissive); + let resp = denial.into_response(); + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "Permissive must keep legacy 401 for HashMismatch" + ); + let ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + ct.contains("application/json"), + "Permissive must keep JSON CT, got: {ct}" + ); + } + #[test] fn serving_write_error_taxonomy_separates_fence_from_backend_failure() { let fenced = anyhow::Error::from(buzz_db::DbError::AccessDenied("fenced".to_string())); @@ -1142,6 +1492,68 @@ mod tests { )); } + // ── Finding 5 (F5): membership denials in Strict mode → authorization denied ─ + + #[tokio::test] + async fn strict_relay_membership_required_produces_nip_fi_403_authorization_denied() { + let denial = MediaDenial( + MediaError::RelayMembershipRequired, + BlossomStrictness::Strict, + ); + let resp = denial.into_response(); + + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "membership denial must be 403 in Strict mode" + ); + let ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + ct.contains("text/plain"), + "expected text/plain CT, got: {ct}" + ); + assert!( + resp.headers().get("www-authenticate").is_none(), + "403 authorization denied must not have WWW-Authenticate" + ); + let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + body.as_ref(), + b"authorization denied\n", + "NIP-FI membership denial body must be 'authorization denied\\n'" + ); + } + + #[tokio::test] + async fn permissive_relay_membership_required_keeps_legacy_json_403() { + // In Permissive mode membership denial falls through to MediaError::into_response() + // which produces the legacy JSON 403. + let denial = MediaDenial( + MediaError::RelayMembershipRequired, + BlossomStrictness::Permissive, + ); + let resp = denial.into_response(); + + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "membership denial must still be 403 in Permissive mode" + ); + let ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + ct.contains("application/json"), + "Permissive membership denial must keep JSON CT, got: {ct}" + ); + } + #[test] fn feedback_inline_allows_only_sniffed_passive_raster_images() { // Real magic bytes for the four verified passive raster formats. @@ -1302,7 +1714,7 @@ mod tests { fn media_get_tags_for(host: &str, sha256: Option<&str>) -> Vec { let now = Timestamp::now().as_secs(); - let expiration = (now + 300).to_string(); + let expiration = (now + 55).to_string(); let mut tags = vec![ Tag::parse(["t", "get"]).expect("t tag"), Tag::parse(["expiration", &expiration]).expect("expiration tag"), @@ -1355,7 +1767,7 @@ mod tests { async fn media_read_rejects_upload_verb_wrong_server_and_wrong_x() { let keys = Keys::generate(); let now = Timestamp::now().as_secs(); - let expiration = (now + 300).to_string(); + let expiration = (now + 55).to_string(); let cases = [ vec![ Tag::parse(["t", "upload"]).expect("t tag"), @@ -1846,29 +2258,52 @@ mod tests { } /// Mint a valid Blossom upload auth header value. + /// NIP-FI-compliant: 55s lifetime, single `server` tag. fn blossom_upload_auth_value(keys: &Keys, host: &str, sha256_hex: &str) -> String { + blossom_upload_auth_value_with(keys, Some(host), sha256_hex, 55) + } + + /// Upload proof with a chosen lifetime and optional `server` tag, for + /// proofs that only the Permissive verifier accepts. + fn blossom_upload_auth_value_with( + keys: &Keys, + server: Option<&str>, + sha256_hex: &str, + lifetime_secs: u64, + ) -> String { use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64; use nostr::JsonUtil as _; - let now = nostr::Timestamp::now().as_secs(); - let exp = now + 300; + let exp = nostr::Timestamp::now().as_secs() + lifetime_secs; + let mut tags = vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["expiration", &exp.to_string()]).unwrap(), + Tag::parse(["x", sha256_hex]).unwrap(), + ]; + if let Some(server) = server { + tags.push(Tag::parse(["server", server]).unwrap()); + } let event = EventBuilder::new(Kind::from(24242), "Upload blob") - .tags(vec![ - Tag::parse(["t", "upload"]).unwrap(), - Tag::parse(["expiration", &exp.to_string()]).unwrap(), - Tag::parse(["server", host]).unwrap(), - Tag::parse(["x", sha256_hex]).unwrap(), - ]) + .tags(tags) .sign_with_keys(keys) .expect("sign blossom upload auth"); format!("Nostr {}", B64.encode(event.as_json().as_bytes())) } /// Mint a valid Blossom get auth header value. + /// NIP-FI-compliant: 55s lifetime, single `server` tag. fn blossom_get_auth_value(keys: &Keys, host: &str, sha256_hex: &str) -> String { + blossom_get_auth_value_with(keys, host, sha256_hex, 55) + } + + fn blossom_get_auth_value_with( + keys: &Keys, + host: &str, + sha256_hex: &str, + lifetime_secs: u64, + ) -> String { use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64; use nostr::JsonUtil as _; - let now = nostr::Timestamp::now().as_secs(); - let exp = now + 300; + let exp = nostr::Timestamp::now().as_secs() + lifetime_secs; let event = EventBuilder::new(Kind::from(24242), "Get blob") .tags(vec![ Tag::parse(["t", "get"]).unwrap(), @@ -1989,12 +2424,33 @@ mod tests { host: &str, authorization: &[&str], assertion: Option<&str>, + ) -> (StatusCode, axum::http::HeaderMap, bytes::Bytes) { + let sha = sha256_hex(AUDIO_BODY); + upload_request_with( + rt, + state, + route, + host, + (Some(&sha), AUDIO_BODY), + authorization, + assertion, + ) + } + + /// `upload_request` with an explicit `X-SHA-256` header (or none) and body. + fn upload_request_with( + rt: &tokio::runtime::Runtime, + state: &Arc, + route: &str, + host: &str, + (x_sha256, body): (Option<&str>, &[u8]), + authorization: &[&str], + assertion: Option<&str>, ) -> (StatusCode, axum::http::HeaderMap, bytes::Bytes) { let mut headers = axum::http::HeaderMap::new(); - headers.insert( - "x-sha-256", - sha256_hex(AUDIO_BODY).parse().expect("valid header"), - ); + if let Some(x_sha256) = x_sha256 { + headers.insert("x-sha-256", x_sha256.parse().expect("valid header")); + } for value in authorization { headers.append( axum::http::header::AUTHORIZATION, @@ -2013,7 +2469,7 @@ mod tests { route, host, headers, - AUDIO_BODY, + body, )) } @@ -3362,5 +3818,473 @@ mod tests { "admitted upload MUST release its per-key slot" ); } + + // ── Blossom strictness follows NIP-FI mode ────────────────────────── + // + // Enforce selects the Strict kind-24242 verifier; Off keeps the + // pre-NIP-FI Permissive one. Each proof below is accepted by + // Permissive and rejected by Strict (lifetime > 60s, or no `server` + // tag), so reverting `blossom_strictness_from_state` to Permissive + // turns the Enforce denials into the 415 the Off cases pin. + + fn permissive_only_upload_proofs(keys: &Keys, host: &str) -> [(String, &'static str); 2] { + let sha = sha256_hex(AUDIO_BODY); + [ + ( + blossom_upload_auth_value_with(keys, Some(host), &sha, 300), + "300s lifetime", + ), + ( + blossom_upload_auth_value_with(keys, None, &sha, 55), + "no server tag", + ), + ] + } + + fn mutate_state(state: &mut Arc, f: impl FnOnce(&mut crate::config::Config)) { + let state = Arc::get_mut(state).expect("fixture state is uniquely owned"); + f(Arc::make_mut(&mut state.config)); + } + + fn media_read( + rt: &tokio::runtime::Runtime, + state: &Arc, + method: &str, + host: &str, + authorization: &str, + assertion: Option<&str>, + ) -> (StatusCode, axum::http::HeaderMap, bytes::Bytes) { + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + authorization.parse().expect("valid header bytes"), + ); + if let Some(assertion) = assertion { + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}").parse().expect("valid header"), + ); + } + let path = format!("/media/{}", "a".repeat(64)); + rt.block_on(media_oneshot( + Arc::clone(state), + method, + &path, + host, + headers, + b"", + )) + } + + #[test] + #[ignore = "requires Postgres"] + fn upload_enforce_strict_rejects_permissive_only_proof() { + let (rt, state, host) = media_fixture(media_enforce_test_state()); + let keys = Keys::generate(); + let assertion = signed_assertion(&keys.public_key().to_hex()); + for (proof, case) in permissive_only_upload_proofs(&keys, &host) { + for route in UPLOAD_ROUTES { + assert_exact_response( + &upload_request(&rt, &state, route, &host, &[&proof], Some(&assertion)), + StatusCode::FORBIDDEN, + "text/plain; charset=utf-8", + None, + b"evidence rejected\n", + &format!("Enforce {route}: {case}"), + ); + } + } + } + + #[test] + #[ignore = "requires Postgres"] + fn upload_off_permissive_accepts_permissive_only_proof() { + let (rt, state, host) = media_fixture(media_off_test_state()); + for (proof, case) in permissive_only_upload_proofs(&Keys::generate(), &host) { + for route in UPLOAD_ROUTES { + assert_exact_response( + &upload_request(&rt, &state, route, &host, &[&proof], None), + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "application/json", + None, + AUDIO_REJECTION, + &format!("Off {route}: {case}"), + ); + } + } + } + + /// GET and HEAD each derive strictness independently; HEAD carries the + /// same denial headers with the body suppressed. + const READ_METHODS: [(&str, &[u8]); 2] = [("GET", b"evidence rejected\n"), ("HEAD", b"")]; + + #[test] + #[ignore = "requires Postgres"] + fn read_strictness_follows_nip_fi_mode() { + let keys = Keys::generate(); + let sha = "a".repeat(64); + + let (rt, state, host) = media_fixture(media_enforce_test_state()); + let assertion = signed_assertion(&keys.public_key().to_hex()); + let proof = blossom_get_auth_value_with(&keys, &host, &sha, 300); + for (method, body) in READ_METHODS { + assert_exact_response( + &media_read(&rt, &state, method, &host, &proof, Some(&assertion)), + StatusCode::FORBIDDEN, + "text/plain; charset=utf-8", + None, + body, + &format!("Enforce {method}: 300s lifetime, non-member"), + ); + } + + let (rt, state, host) = media_fixture(media_off_test_state()); + let proof = blossom_get_auth_value_with(&keys, &host, &sha, 300); + for (method, _) in READ_METHODS { + let (status, _, body) = media_read(&rt, &state, method, &host, &proof, None); + assert_eq!( + status, + StatusCode::NOT_FOUND, + "Off {method} must pass Permissive auth and reach the sidecar gate; \ + body {body:?}" + ); + } + } + + // ── Off keeps main's Permissive `t` predicate [FI-INV-15] ─────────── + + /// Blossom proof with the given `t` tags plus valid expiration, server, + /// and x tags. + fn blossom_auth_value_with_t( + keys: &Keys, + t_tags: &[&[&str]], + host: &str, + sha256_hex: &str, + ) -> String { + use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64; + use nostr::JsonUtil as _; + let exp = (nostr::Timestamp::now().as_secs() + 55).to_string(); + let mut tags: Vec = t_tags + .iter() + .map(|t| Tag::parse(t.iter().copied()).unwrap()) + .collect(); + tags.push(Tag::parse(["expiration", &exp]).unwrap()); + tags.push(Tag::parse(["server", host]).unwrap()); + tags.push(Tag::parse(["x", sha256_hex]).unwrap()); + let event = EventBuilder::new(Kind::from(24242), "Blossom auth") + .tags(tags) + .sign_with_keys(keys) + .expect("sign blossom auth"); + format!("Nostr {}", B64.encode(event.as_json().as_bytes())) + } + + const LEGACY_AUTH_FAILED: &[u8] = br#"{"error":"authentication failed"}"#; + + /// An empty-valued `t` beside a valid one is rejected with main's legacy + /// 401 JSON in either order; a valueless `t` is ignored, as on main. + #[test] + #[ignore = "requires Postgres"] + fn off_empty_t_tag_keeps_legacy_401_valueless_is_ignored() { + let (rt, state, host) = media_fixture(media_off_test_state()); + let keys = Keys::generate(); + let read_sha = "a".repeat(64); + let upload_sha = sha256_hex(AUDIO_BODY); + for (extra, rejected) in [(&["t", ""][..], true), (&["t"][..], false)] { + for (verb_first, order) in [(true, "verb first"), (false, "verb last")] { + let t_tags = |verb: &'static str| -> Vec<&[&str]> { + let verb: &[&str] = if verb == "get" { + &["t", "get"] + } else { + &["t", "upload"] + }; + if verb_first { + vec![verb, extra] + } else { + vec![extra, verb] + } + }; + let context = format!("Off {extra:?} {order}"); + let read_proof = + blossom_auth_value_with_t(&keys, &t_tags("get"), &host, &read_sha); + let read = media_read(&rt, &state, "GET", &host, &read_proof, None); + let upload_proof = + blossom_auth_value_with_t(&keys, &t_tags("upload"), &host, &upload_sha); + if rejected { + assert_exact_response( + &read, + StatusCode::UNAUTHORIZED, + "application/json", + None, + LEGACY_AUTH_FAILED, + &format!("{context} read"), + ); + } else { + assert_eq!(read.0, StatusCode::NOT_FOUND, "{context} read"); + } + for route in UPLOAD_ROUTES { + let response = + upload_request(&rt, &state, route, &host, &[&upload_proof], None); + if rejected { + assert_exact_response( + &response, + StatusCode::UNAUTHORIZED, + "application/json", + None, + LEGACY_AUTH_FAILED, + &format!("{context} {route}"), + ); + } else { + assert_exact_response( + &response, + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "application/json", + None, + AUDIO_REJECTION, + &format!("{context} {route}"), + ); + } + } + } + } + } + + // ── Upload hash denials go through the handler in both modes ──────── + + /// Decodable 1x1 PNG: passes content validation on both upload routes, + /// so a signed/header hash that differs from its digest is rejected by + /// the post-body hash check before any storage call. + const PNG_BODY: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, + 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, + 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x78, + 0x9c, 0x63, 0xf8, 0xff, 0xff, 0x3f, 0x00, 0x05, 0xfe, 0x02, 0xfe, 0x0d, 0xef, 0x46, + 0xb8, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + ]; + + fn assert_upload_hash_denials(enforce: bool) { + let (rt, state, host) = if enforce { + media_fixture(media_enforce_test_state()) + } else { + media_fixture(media_off_test_state()) + }; + let keys = Keys::generate(); + let assertion = signed_assertion(&keys.public_key().to_hex()); + let assertion = enforce.then_some(assertion.as_str()); + let audio_sha = sha256_hex(AUDIO_BODY); + let other_sha = "b".repeat(64); + let audio_proof = blossom_upload_auth_value(&keys, &host, &audio_sha); + let other_proof = blossom_upload_auth_value(&keys, &host, &other_sha); + /// `X-SHA-256` header value and request body. + type HashRequest<'a> = (Option<&'a str>, &'a [u8]); + let cases: [(&str, HashRequest, &str); 4] = [ + ("X-SHA-256 missing", (None, AUDIO_BODY), &audio_proof), + ( + "X-SHA-256 malformed", + (Some("not-hex"), AUDIO_BODY), + &audio_proof, + ), + ( + "X-SHA-256 not in signed x", + (Some(&other_sha), AUDIO_BODY), + &audio_proof, + ), + ( + "body hash differs from signed/header hash", + (Some(&other_sha), PNG_BODY), + &other_proof, + ), + ]; + for (case, request, proof) in cases { + for route in UPLOAD_ROUTES { + let response = upload_request_with( + &rt, + &state, + route, + &host, + request, + &[proof], + assertion, + ); + let context = format!("enforce={enforce} {route}: {case}"); + if enforce { + assert_exact_response( + &response, + StatusCode::FORBIDDEN, + "text/plain; charset=utf-8", + None, + b"evidence rejected\n", + &context, + ); + } else { + assert_exact_response( + &response, + StatusCode::UNAUTHORIZED, + "application/json", + None, + LEGACY_AUTH_FAILED, + &context, + ); + } + } + } + } + + #[test] + #[ignore = "requires Postgres"] + fn upload_enforce_hash_denials_are_evidence_rejected() { + assert_upload_hash_denials(true); + } + + #[test] + #[ignore = "requires Postgres"] + fn upload_off_hash_denials_keep_legacy_json_401() { + assert_upload_hash_denials(false); + } + + /// DenyProtected answers 503 before any proof check, so even a + /// compliant proof never reaches the Blossom verifier. + #[test] + #[ignore = "requires Postgres"] + fn media_deny_protected_is_503_before_proof_checks() { + let (rt, mut state, host) = media_fixture(media_off_test_state()); + mutate_state(&mut state, |c| c.nip_fi.mode = NipFiMode::DenyProtected); + let keys = Keys::generate(); + let class = buzz_auth::DenialClass::AuthorizationUnavailable; + let upload_proof = blossom_upload_auth_value(&keys, &host, &sha256_hex(AUDIO_BODY)); + let read_proof = blossom_get_auth_value(&keys, &host, &"a".repeat(64)); + for (response, context) in [ + ( + upload_request(&rt, &state, "/upload", &host, &[&upload_proof], None), + "upload", + ), + ( + media_read(&rt, &state, "GET", &host, &read_proof, None), + "read", + ), + ] { + assert_eq!(response.0, StatusCode::SERVICE_UNAVAILABLE, "{context}"); + assert_eq!( + response.2.as_ref(), + class.http_body().as_bytes(), + "{context}" + ); + } + } + + // ── Membership denial shape follows NIP-FI mode ───────────────────── + + #[test] + #[ignore = "requires Postgres"] + fn enforce_membership_denial_is_authorization_denied() { + let (rt, mut state, host) = media_fixture(media_enforce_test_state()); + mutate_state(&mut state, |c| c.require_relay_membership = true); + let keys = Keys::generate(); + let assertion = signed_assertion(&keys.public_key().to_hex()); + let upload_proof = blossom_upload_auth_value(&keys, &host, &sha256_hex(AUDIO_BODY)); + let read_proof = blossom_get_auth_value(&keys, &host, &"a".repeat(64)); + for (response, context) in [ + ( + upload_request( + &rt, + &state, + "/upload", + &host, + &[&upload_proof], + Some(&assertion), + ), + "upload", + ), + ( + media_read(&rt, &state, "GET", &host, &read_proof, Some(&assertion)), + "read", + ), + ] { + assert_exact_response( + &response, + StatusCode::FORBIDDEN, + "text/plain; charset=utf-8", + None, + b"authorization denied\n", + &format!("Enforce {context} non-member"), + ); + } + assert_exact_response( + &media_read(&rt, &state, "HEAD", &host, &read_proof, Some(&assertion)), + StatusCode::FORBIDDEN, + "text/plain; charset=utf-8", + None, + b"", + "Enforce HEAD non-member", + ); + } + + #[test] + #[ignore = "requires Postgres"] + fn off_membership_denial_keeps_legacy_json_403() { + let (rt, mut state, host) = media_fixture(media_off_test_state()); + mutate_state(&mut state, |c| c.require_relay_membership = true); + let keys = Keys::generate(); + let upload_proof = blossom_upload_auth_value(&keys, &host, &sha256_hex(AUDIO_BODY)); + let read_proof = blossom_get_auth_value(&keys, &host, &"a".repeat(64)); + for (response, context) in [ + ( + upload_request(&rt, &state, "/upload", &host, &[&upload_proof], None), + "upload", + ), + ( + media_read(&rt, &state, "GET", &host, &read_proof, None), + "read", + ), + ] { + assert_exact_response( + &response, + StatusCode::FORBIDDEN, + "application/json", + None, + br#"{"error":"relay membership required"}"#, + &format!("Off {context} non-member"), + ); + } + assert_exact_response( + &media_read(&rt, &state, "HEAD", &host, &read_proof, None), + StatusCode::FORBIDDEN, + "application/json", + None, + b"", + "Off HEAD non-member", + ); + } + + /// Positive control: a relay member passes the gate under Enforce and + /// reaches the sidecar check (404 for an unknown blob), so the denials + /// above are membership-specific rather than blanket rejections. + #[test] + #[ignore = "requires Postgres"] + fn enforce_relay_member_read_reaches_sidecar() { + let (rt, mut state, host) = media_fixture(media_enforce_test_state()); + mutate_state(&mut state, |c| c.require_relay_membership = true); + let keys = Keys::generate(); + let community = rt + .block_on(state.db.ensure_configured_community(&host)) + .expect("community"); + rt.block_on(state.db.add_relay_member( + community.id, + &keys.public_key().to_hex(), + "member", + None, + )) + .expect("add relay member"); + let assertion = signed_assertion(&keys.public_key().to_hex()); + let proof = blossom_get_auth_value(&keys, &host, &"a".repeat(64)); + for method in ["GET", "HEAD"] { + let (status, _, body) = + media_read(&rt, &state, method, &host, &proof, Some(&assertion)); + assert_eq!( + status, + StatusCode::NOT_FOUND, + "member {method}; body {body:?}" + ); + } + } } } diff --git a/crates/buzz-test-client/tests/e2e_media.rs b/crates/buzz-test-client/tests/e2e_media.rs index 690fd9c8a5..89484b798a 100644 --- a/crates/buzz-test-client/tests/e2e_media.rs +++ b/crates/buzz-test-client/tests/e2e_media.rs @@ -26,6 +26,17 @@ fn relay_http_url() -> String { std::env::var("RELAY_HTTP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()) } +/// Extract the host:port authority from the relay URL for use as the `server` tag. +fn relay_server_authority() -> String { + let url = relay_http_url(); + url.trim_start_matches("https://") + .trim_start_matches("http://") + .split('/') + .next() + .unwrap_or("localhost:3000") + .to_string() +} + fn http_client() -> Client { Client::builder() .timeout(Duration::from_secs(15)) @@ -36,11 +47,13 @@ fn http_client() -> Client { /// Sign a kind:24242 Blossom upload auth event for the given sha256. fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { let now = Timestamp::now().as_secs(); - let exp_str = (now + 300).to_string(); + let exp_str = (now + 55).to_string(); + let server = relay_server_authority(); let tags = vec![ Tag::parse(["t", "upload"]).expect("t tag"), Tag::parse(["x", sha256]).expect("x tag"), Tag::parse(["expiration", &exp_str]).expect("expiration tag"), + Tag::parse(["server", &server]).expect("server tag"), ]; EventBuilder::new(Kind::from(24242), "Upload test") .tags(tags) @@ -56,11 +69,13 @@ fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { /// one token serves `{sha}.jpg` and `{sha}.thumb.jpg` alike. fn sign_blossom_get_auth(keys: &Keys, sha256: &str) -> nostr::Event { let now = Timestamp::now().as_secs(); - let exp_str = (now + 300).to_string(); + let exp_str = (now + 55).to_string(); + let server = relay_server_authority(); let tags = vec![ Tag::parse(["t", "get"]).expect("t tag"), Tag::parse(["x", sha256]).expect("x tag"), Tag::parse(["expiration", &exp_str]).expect("expiration tag"), + Tag::parse(["server", &server]).expect("server tag"), ]; EventBuilder::new(Kind::from(24242), "Get test") .tags(tags) diff --git a/crates/buzz-test-client/tests/e2e_media_extended.rs b/crates/buzz-test-client/tests/e2e_media_extended.rs index d8adfaed98..9bd73b13fc 100644 --- a/crates/buzz-test-client/tests/e2e_media_extended.rs +++ b/crates/buzz-test-client/tests/e2e_media_extended.rs @@ -19,6 +19,17 @@ fn relay_ws_url() -> String { .replace("https://", "wss://") } +/// Extract the host:port authority from the relay URL for use as the `server` tag. +fn relay_server_authority() -> String { + let url = relay_http_url(); + url.trim_start_matches("https://") + .trim_start_matches("http://") + .split('/') + .next() + .unwrap_or("localhost:3000") + .to_string() +} + fn http_client() -> Client { Client::builder() .timeout(Duration::from_secs(15)) @@ -28,10 +39,12 @@ fn http_client() -> Client { fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { let now = Timestamp::now().as_secs(); + let server = relay_server_authority(); let tags = vec![ Tag::parse(["t", "upload"]).unwrap(), Tag::parse(["x", sha256]).unwrap(), - Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(), + Tag::parse(["expiration", &(now + 55).to_string()]).unwrap(), + Tag::parse(["server", &server]).unwrap(), ]; EventBuilder::new(Kind::from(24242), "Upload test") .tags(tags) @@ -43,10 +56,12 @@ fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { /// unconditionally, so round-trip GETs must present one of these. fn sign_blossom_get_auth(keys: &Keys, sha256: &str) -> nostr::Event { let now = Timestamp::now().as_secs(); + let server = relay_server_authority(); let tags = vec![ Tag::parse(["t", "get"]).unwrap(), Tag::parse(["x", sha256]).unwrap(), - Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(), + Tag::parse(["expiration", &(now + 55).to_string()]).unwrap(), + Tag::parse(["server", &server]).unwrap(), ]; EventBuilder::new(Kind::from(24242), "Get test") .tags(tags) @@ -259,7 +274,7 @@ async fn test_auth_wrong_kind() { vec![ Tag::parse(["t", "upload"]).unwrap(), Tag::parse(["x", &sha256]).unwrap(), - Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(), + Tag::parse(["expiration", &(now + 55).to_string()]).unwrap(), ], ); let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await; @@ -281,7 +296,7 @@ async fn test_auth_missing_t_tag() { "Upload test", vec![ Tag::parse(["x", &sha256]).unwrap(), - Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(), + Tag::parse(["expiration", &(now + 55).to_string()]).unwrap(), ], ); let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await; @@ -348,7 +363,7 @@ async fn test_auth_empty_content() { vec![ Tag::parse(["t", "upload"]).unwrap(), Tag::parse(["x", &sha256]).unwrap(), - Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(), + Tag::parse(["expiration", &(now + 55).to_string()]).unwrap(), ], ); let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await; @@ -371,7 +386,7 @@ async fn test_auth_server_tag_mismatch() { vec![ Tag::parse(["t", "upload"]).unwrap(), Tag::parse(["x", &sha256]).unwrap(), - Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(), + Tag::parse(["expiration", &(now + 55).to_string()]).unwrap(), Tag::parse(["server", "evil.example.com"]).unwrap(), ], ); @@ -395,7 +410,7 @@ async fn test_auth_server_tag_correct() { vec![ Tag::parse(["t", "upload"]).unwrap(), Tag::parse(["x", &sha256]).unwrap(), - Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(), + Tag::parse(["expiration", &(now + 55).to_string()]).unwrap(), Tag::parse(["server", "localhost:3000"]).unwrap(), ], ); diff --git a/crates/buzz-test-client/tests/e2e_media_video.rs b/crates/buzz-test-client/tests/e2e_media_video.rs index 2ec0b1e698..819838aa2c 100644 --- a/crates/buzz-test-client/tests/e2e_media_video.rs +++ b/crates/buzz-test-client/tests/e2e_media_video.rs @@ -19,6 +19,17 @@ fn relay_http_url() -> String { std::env::var("RELAY_HTTP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()) } +/// Extract the host:port authority from the relay URL for use as the `server` tag. +fn relay_server_authority() -> String { + let url = relay_http_url(); + url.trim_start_matches("https://") + .trim_start_matches("http://") + .split('/') + .next() + .unwrap_or("localhost:3000") + .to_string() +} + fn http_client() -> Client { Client::builder() .timeout(Duration::from_secs(30)) @@ -28,11 +39,13 @@ fn http_client() -> Client { fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { let now = Timestamp::now().as_secs(); - let exp_str = (now + 300).to_string(); + let exp_str = (now + 55).to_string(); + let server = relay_server_authority(); let tags = vec![ Tag::parse(["t", "upload"]).expect("t tag"), Tag::parse(["x", sha256]).expect("x tag"), Tag::parse(["expiration", &exp_str]).expect("expiration tag"), + Tag::parse(["server", &server]).expect("server tag"), ]; EventBuilder::new(Kind::from(24242), "Upload test") .tags(tags) @@ -45,11 +58,13 @@ fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { /// the 206 and 416 range behaviour below would never be reached. fn sign_blossom_get_auth(keys: &Keys, sha256: &str) -> nostr::Event { let now = Timestamp::now().as_secs(); - let exp_str = (now + 300).to_string(); + let exp_str = (now + 55).to_string(); + let server = relay_server_authority(); let tags = vec![ Tag::parse(["t", "get"]).expect("t tag"), Tag::parse(["x", sha256]).expect("x tag"), Tag::parse(["expiration", &exp_str]).expect("expiration tag"), + Tag::parse(["server", &server]).expect("server tag"), ]; EventBuilder::new(Kind::from(24242), "Get test") .tags(tags) diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 7e908d35c5..20924147f0 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -294,20 +294,21 @@ pub(crate) fn detect_and_validate_mime(body: &[u8]) -> Result { Ok(mime) } -/// Lifetime of a Blossom `t=get` read token. Ten minutes keeps a token alive -/// across a video's range-request stream while staying well inside the -/// server's `created_at` freshness window (3600s, matching upload). -pub(crate) const MEDIA_GET_AUTH_EXPIRY_SECS: u64 = 600; +/// Lifetime of a Blossom `t=get` read token. +/// +/// 60 seconds is the maximum permitted by NIP-FI §Freshness: `expiration <= created_at + 60s`. +/// Callers that make range requests across longer video playback sessions must re-mint on +/// a 401 or 403 from the server (expired/rejected token), using a single fresh mint+retry. +pub(crate) const MEDIA_GET_AUTH_EXPIRY_SECS: u64 = 60; /// Sign a Blossom (BUD-01) `t=get` authorization event, server-scoped to the /// relay's authority, and return the full `Authorization` header value. /// /// Server-scoped (a `server` tag, no `x` tag): one token authorizes reads of -/// any blob on that host for its lifetime, which keeps avatar-grid bursts and -/// video range requests cheap. This is deliberately broader than per-blob -/// scoping and is safe only because the relay still enforces NIP-43 -/// membership on the verified pubkey — and because callers only attach this -/// header to requests bound for the relay origin itself. +/// any blob on that host for its lifetime. Callers must only attach this +/// header to requests bound for the relay origin itself (never third-party +/// origins). Tokens expire after `expiry_secs` (≤ 60 per NIP-FI §Freshness) +/// and must be re-minted on 401 or 403 for long-running range-request streams. pub(crate) fn sign_blossom_get_auth_header( keys: &Keys, base_url: &str, @@ -365,16 +366,16 @@ pub(crate) fn sign_blossom_upload_auth( expiry_secs: u64, base_url: &str, ) -> Result { + let server = extract_server_authority(base_url) + .ok_or_else(|| "cannot derive server authority from relay URL".to_string())?; let now = Timestamp::now().as_secs(); - let mut tags = vec![ + let tags = vec![ Tag::parse(vec!["t", "upload"]).map_err(|e| e.to_string())?, Tag::parse(vec!["x", sha256]).map_err(|e| e.to_string())?, Tag::parse(vec!["expiration", &(now + expiry_secs).to_string()]) .map_err(|e| e.to_string())?, + Tag::parse(vec!["server".to_string(), server]).map_err(|e| e.to_string())?, ]; - if let Some(domain) = extract_server_authority(base_url) { - tags.push(Tag::parse(vec!["server".to_string(), domain]).map_err(|e| e.to_string())?); - } EventBuilder::new(Kind::from(24242), "Upload buzz-media") .tags(tags) .sign_with_keys(keys) @@ -414,14 +415,12 @@ async fn do_upload( ) -> Result { let sha256 = hex::encode(Sha256::digest(&body)); - // Video uploads get a 1-hour auth window to survive slow connections; - // images use 5 minutes. Must match the server-side max_age_secs values - // in process_upload (600s) and process_video_upload (3600s). - let expiry_secs = if mime.starts_with("video/") { - 3600 - } else { - 300 - }; + // All upload tokens use a 60-second expiry window per NIP-FI §Freshness: + // `expiration <= created_at + 60s`. Upload tokens are minted immediately + // before the PUT request, so 60 seconds is ample for any upload over a + // reasonable connection (the body is already hashed and ready to send). + // The server-side window is also 60s in Strict mode, matching this value. + let expiry_secs = 60u64; let base_url = relay_api_base_url_with_override(state); let auth_event = { let keys = state.signing_keys()?; @@ -843,7 +842,7 @@ mod tests { #[test] fn test_sign_blossom_get_auth_header_shape() { let keys = Keys::generate(); - let header = sign_blossom_get_auth_header(&keys, "http://localhost:3000", 600).unwrap(); + let header = sign_blossom_get_auth_header(&keys, "http://localhost:3000", 60).unwrap(); let b64 = header.strip_prefix("Nostr ").expect("Nostr scheme prefix"); let json = URL_SAFE_NO_PAD.decode(b64).unwrap(); let event = nostr::Event::from_json(std::str::from_utf8(&json).unwrap()).unwrap(); @@ -863,13 +862,48 @@ mod tests { assert!(tag("x").is_none()); let expiration: u64 = tag("expiration").unwrap().parse().unwrap(); let now = Timestamp::now().as_secs(); - assert!(expiration > now && expiration <= now + 600); + // Token expiry must be within the 60s NIP-FI window. + assert!(expiration > now && expiration <= now + 60); } #[test] fn test_sign_blossom_get_auth_header_invalid_base_url() { let keys = Keys::generate(); - assert!(sign_blossom_get_auth_header(&keys, "not-a-url", 600).is_err()); + assert!(sign_blossom_get_auth_header(&keys, "not-a-url", 60).is_err()); + } + + #[test] + fn test_sign_blossom_upload_auth_shape() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let base_url = "http://relay.example:3000"; + let event = sign_blossom_upload_auth(&keys, &sha256, 60, base_url).unwrap(); + + assert_eq!(event.kind, nostr::Kind::from(24242)); + event.verify().expect("valid signature"); + + let tag = |name: &str| -> Option { + event.tags.iter().find_map(|t| { + let v = t.as_slice(); + (v.first().map(String::as_str) == Some(name)).then(|| v[1].clone()) + }) + }; + assert_eq!(tag("t").as_deref(), Some("upload")); + assert_eq!(tag("x").as_deref(), Some(sha256.as_str())); + // server tag MUST be present (NIP-FI §Upload proofs) + assert_eq!(tag("server").as_deref(), Some("relay.example:3000")); + // expiry must be within the 60s NIP-FI window + let expiration: u64 = tag("expiration").unwrap().parse().unwrap(); + let now = nostr::Timestamp::now().as_secs(); + assert!(expiration > now && expiration <= now + 60); + } + + #[test] + fn test_sign_blossom_upload_auth_fails_without_valid_url() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + // URL that cannot yield a server authority → should fail (server tag mandatory) + assert!(sign_blossom_upload_auth(&keys, &sha256, 60, "not-a-url").is_err()); } #[test] diff --git a/desktop/src-tauri/src/media_proxy.rs b/desktop/src-tauri/src/media_proxy.rs index 21692ce523..79128eca0f 100644 --- a/desktop/src-tauri/src/media_proxy.rs +++ b/desktop/src-tauri/src/media_proxy.rs @@ -76,6 +76,38 @@ async fn proxy_handler(AxumState(state): AxumState, req: Request) -> } }; + // Re-mint and retry once on an auth failure (401 missing_evidence or 403 + // evidence_rejected). Under NIP-FI strict mode a read token expires after + // 60s, so a long video range-request stream will exhaust its token mid-flight. + // Single retry; if the fresh token also fails, propagate the error to the caller. + let resp = if (resp.status() == reqwest::StatusCode::UNAUTHORIZED + || resp.status() == reqwest::StatusCode::FORBIDDEN) + && has_range + { + if let Some(fresh_auth) = mint_media_get_auth(&app_state, &base_url) { + let mut retry = state + .client + .get(&upstream_url) + .timeout(std::time::Duration::from_secs(120)) + .header("authorization", fresh_auth); + if let Some(range) = req.headers().get("range") { + if let Ok(v) = range.to_str() { + retry = retry.header("range", v); + } + } + match retry.send().await { + Ok(r) => r, + Err(_) => { + return (StatusCode::BAD_GATEWAY, "upstream request failed").into_response(); + } + } + } else { + resp + } + } else { + resp + }; + let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); @@ -199,6 +231,34 @@ pub async fn handle_buzz_media( let result = upstream.send().await; + // Re-mint and retry once on auth failure (401/403) for range requests. + // Under NIP-FI strict mode a read token expires after 60s, exhausting + // mid-stream. Single retry with a fresh token; propagate on second failure. + let result = match result { + Ok(resp) + if (resp.status() == reqwest::StatusCode::UNAUTHORIZED + || resp.status() == reqwest::StatusCode::FORBIDDEN) + && has_range => + { + if let Some(fresh_auth) = mint_media_get_auth(&state, &base) { + let mut retry = state + .http_client + .get(&upstream_url) + .timeout(std::time::Duration::from_secs(60)) + .header("authorization", fresh_auth); + if let Some(range) = request.headers().get("range") { + if let Ok(v) = range.to_str() { + retry = retry.header("range", v); + } + } + retry.send().await + } else { + Ok(resp) + } + } + other => other, + }; + match result { Ok(resp) => { let status = resp.status().as_u16(); diff --git a/mobile/lib/features/channels/media_viewer_page/video_viewer.dart b/mobile/lib/features/channels/media_viewer_page/video_viewer.dart index 7267795734..02bebfadd0 100644 --- a/mobile/lib/features/channels/media_viewer_page/video_viewer.dart +++ b/mobile/lib/features/channels/media_viewer_page/video_viewer.dart @@ -19,6 +19,13 @@ class MediaVideoViewerPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final controller = useState(null); + // Tracks a VideoPlayerController that has been created (i.e. platform + // resources allocated via createWithOptions()) but whose initialize()+play() + // chain has not yet completed or failed. The effect cleanup path disposes + // this directly so a close-during-init does not leak the native player, + // even when the async chain is suspended waiting for the initialized event + // or for play() to return. + final pendingController = useRef(null); final videoFile = useRef(null); final downloadRequestAbort = useRef?>(null); final downloadSubscription = useRef>?>(null); @@ -48,34 +55,13 @@ class MediaVideoViewerPage extends HookConsumerWidget { final auth = ref.read(mediaGetAuthServiceProvider); final uri = Uri.parse(videoUrl); - // ExoPlayer supports the request headers on every range request, so - // keep Android on its streaming path. iOS uses the authenticated local - // copy below because AVPlayer can drop those headers after the first - // request. - if (Platform.isAndroid) { - VideoPlayerController? streamingController; - try { - streamingController = VideoPlayerController.networkUrl( - uri, - httpHeaders: auth.headersFor(videoUrl), - ); - await streamingController.initialize(); - await streamingController.play(); - if (disposed) { - await streamingController.dispose(); - return; - } - controller.value = streamingController; - return; - } catch (_) { - if (streamingController != null) { - await streamingController.dispose(); - } - // Fall through to the authenticated local-file path only when the - // streaming controller cannot initialize. - } - } - + // All platforms: download to an authenticated local file so the proof + // is bound at request time rather than frozen into controller headers. + // (iOS already used this path; Android previously used streaming headers + // but video_player_android 2.9.5 freezes those headers into static + // DefaultHttpDataSource request properties — a proof minted at + // controller creation time becomes stale after 60 s, causing seeks + // outside the buffer to fail with expiry rejection.) try { final client = ref.read(mediaHttpClientProvider); final requestAbort = Completer(); @@ -85,6 +71,14 @@ class MediaVideoViewerPage extends HookConsumerWidget { uri, abortTrigger: requestAbort.future, )..headers.addAll(auth.headersFor(videoUrl)); + // A GET carries no request body. StreamedRequest's sink MUST be + // closed to signal end-of-stream: IOClient.send() awaits + // stream.pipe(ioRequest) before returning a response, and pipe + // blocks until the source stream ends. Without close(), every + // download hangs in loading until the request is aborted. + // close() is unawaited because it may not complete until after + // the pipe is in progress (streamed_request.dart:15-29). + unawaited(request.sink.close()); late final http.StreamedResponse response; try { response = await client.send(request); @@ -98,7 +92,12 @@ class MediaVideoViewerPage extends HookConsumerWidget { return; } if (response.statusCode < 200 || response.statusCode >= 300) { - await response.stream.drain(); + // Cancel (not drain) the error-body stream so a stalled server body + // cannot hold the download open. `drain()` waits for the upstream + // to close the stream; `_cancelVideoResponse` subscribes and + // immediately cancels, which closes the underlying connection without + // waiting for the full response body [F2r(b)]. + await _cancelVideoResponse(response); throw HttpException( 'Video download failed (${response.statusCode})', uri: uri, @@ -146,14 +145,72 @@ class MediaVideoViewerPage extends HookConsumerWidget { } final localController = VideoPlayerController.file(file); - await localController.initialize(); - await localController.play(); - if (disposed) { - await localController.dispose(); - await deleteVideoFile(); - return; + // Register as pending BEFORE the first async suspension + // (initialize()) so the effect cleanup can always reach it. + // video_player 2.11.1 allocates the native player synchronously + // inside createWithOptions() before _creatingCompleter completes; + // a close arriving at any point after this line will find the + // controller in pendingController and dispose it correctly. + pendingController.value = localController; + // Own the controller before any async suspension so a failed + // initialize() or play() — or a disposal that races with init — + // can always call dispose() unconditionally [F2r(a)]. + // video_player 2.11.1 completes the init future with an error on + // native failure but does NOT dispose the player; Android 2.9.5 + // retains the native player until explicit disposal. Without this + // wrapper, a PlatformException from initialize() unwinds to the + // outer catch where controller.value is still null, so the cleanup + // teardown's `if (activeController != null)` guard silently skips + // disposal — leaking the native player and its event subscription. + try { + await localController.initialize(); + if (disposed) { + // Effect cleanup will also see pendingController.value and + // dispose it; clear the ref here to avoid a double-dispose. + pendingController.value = null; + await localController.dispose(); + await deleteVideoFile(); + return; + } + await localController.play(); + if (disposed) { + pendingController.value = null; + await localController.dispose(); + await deleteVideoFile(); + return; + } + pendingController.value = null; + controller.value = localController; + } catch (_) { + // Start disposal without awaiting it, then rethrow immediately. + // + // video_player 2.11.1 initialize() creates _creatingCompleter at + // the top of the method, then awaits createWithOptions() before + // completing it (video_player.dart:546,587-590). If + // createWithOptions() itself throws, _creatingCompleter is never + // completed, and dispose() waits on it unconditionally at :682-683. + // Awaiting dispose() here would therefore deadlock: the outer catch + // never sets error.value, the error UI is never shown, and the + // viewer is left in an infinite loading state. + // + // Note: if createWithOptions() throws, _creatingCompleter is never + // completed, so the unawaited disposal stalls at the same wait. + // This bypasses the deadlock for the outer catch but does not + // release the native player in the create-failure case. After a + // successful create, _creatingCompleter is completed at :590, so + // the detached disposal runs normally; errors from that detached + // future are caught and logged below rather than becoming uncaught + // async errors [F2r(d)]. + pendingController.value = null; + unawaited( + localController.dispose().catchError((Object disposeError) { + debugPrint( + '[VideoViewer] dispose() failed after load error: $disposeError', + ); + }), + ); + rethrow; } - controller.value = localController; } catch (loadError) { if (!disposed) error.value = loadError.toString(); } @@ -168,6 +225,15 @@ class MediaVideoViewerPage extends HookConsumerWidget { } unawaited(downloadSubscription.value?.cancel() ?? Future.value()); unawaited(downloadSink.value?.close() ?? Future.value()); + // Dispose whichever controller is reachable: a controller that has + // finished init+play and been published to controller.value, OR one + // that is still mid-init (registered in pendingController before the + // first await). Exactly one of these is non-null at any moment; + // clearing both refs prevents a double-dispose if initializeVideo() + // races with the teardown. + final activePending = pendingController.value; + pendingController.value = null; + if (activePending != null) unawaited(activePending.dispose()); final activeController = controller.value; if (activeController != null) unawaited(activeController.dispose()); unawaited(deleteVideoFile()); diff --git a/mobile/lib/shared/relay/media_auth.dart b/mobile/lib/shared/relay/media_auth.dart index b21eeca36d..8611ce5b56 100644 --- a/mobile/lib/shared/relay/media_auth.dart +++ b/mobile/lib/shared/relay/media_auth.dart @@ -7,10 +7,12 @@ import 'package:nostr/nostr.dart' as nostr; import 'relay_provider.dart'; const _mediaGetAuthKind = 24242; -const _mediaGetAuthLifetimeSeconds = 600; +const _mediaGetAuthLifetimeSeconds = 60; /// Re-sign this long before the cached auth event expires, so an in-flight /// request signed just before the boundary still lands well within validity. +/// With a 60-second lifetime, the margin equals the lifetime: each request +/// mints a fresh token (mint-per-request pattern for NIP-FI compliance). const _mediaGetAuthRefreshMarginSeconds = 60; /// Builds BUD-01 Blossom `t=get` auth headers for relay-host media URLs. @@ -19,11 +21,13 @@ const _mediaGetAuthRefreshMarginSeconds = 60; /// so callers can safely use this on arbitrary profile/custom-emoji URLs without /// leaking Buzz credentials to third-party hosts. /// -/// The signed header is memoized until [_mediaGetAuthRefreshMarginSeconds] -/// before expiry: repeated calls return the byte-identical map instead of -/// producing a fresh Schnorr signature per widget build. The service itself is -/// rebuilt (dropping the memo) whenever the relay config — base URL or signing -/// identity — changes, via [mediaGetAuthServiceProvider]. +/// With a 60-second proof lifetime the refresh margin equals the lifetime, so +/// `_refreshAt == signedAt` and the cache never hits: every call mints a fresh +/// proof (mint-per-request pattern). This is intentional for NIP-FI compliance +/// — caching a 60-second token is staleness-prone and provides no meaningful +/// reduction in signing work. The service itself is rebuilt whenever the relay +/// config — base URL or signing identity — changes, via +/// [mediaGetAuthServiceProvider]. class MediaGetAuthService { final String _baseUrl; final String? _nsec; diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index 4f79080efd..21f5de4df2 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -33,7 +33,7 @@ const _requiresLegacyMediaStoragePermissionMethod = const _readClipboardImageMethod = 'readClipboardImage'; const _clipboardHasImageMethod = 'clipboardHasImage'; const _uploadAuthKind = 24242; -const _uploadAuthLifetimeSeconds = 300; +const _uploadAuthLifetimeSeconds = 60; const _heicBrands = { 'heic', 'heix', @@ -688,12 +688,17 @@ class MediaUploadService { final expiration = (_now().millisecondsSinceEpoch ~/ 1000) + _uploadAuthLifetimeSeconds; + final serverAuthority = extractServerAuthority(_baseUrl); + if (serverAuthority == null) { + throw Exception( + 'Cannot mint upload auth: no server authority in relay URL: $_baseUrl', + ); + } final tags = >[ ['t', 'upload'], ['x', sha256], ['expiration', '$expiration'], - if (extractServerAuthority(_baseUrl) case final authority?) - ['server', authority], + ['server', serverAuthority], ]; return nostr.Event.from( diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 76e9e9d6d4..023e9666fa 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1089,7 +1089,7 @@ packages: source: hosted version: "2.2.1" path_provider_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: path_provider_platform_interface sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" @@ -1129,7 +1129,7 @@ packages: source: hosted version: "3.1.6" plugin_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: plugin_platform_interface sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" @@ -1654,7 +1654,7 @@ packages: source: hosted version: "2.9.4" video_player_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: video_player_platform_interface sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index f4f0bcac7a..5e53e37e98 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -59,6 +59,10 @@ dev_dependencies: custom_lint: ^0.8.0 riverpod_lint: ^3.1.0 mocktail: ^1.0.4 + # Test-only platform interface fakes (depend_on_referenced_packages). + path_provider_platform_interface: ^2.1.2 + plugin_platform_interface: ^2.1.8 + video_player_platform_interface: ^6.6.0 flutter: uses-material-design: true diff --git a/mobile/test/features/channels/media_viewer_page/video_viewer_test.dart b/mobile/test/features/channels/media_viewer_page/video_viewer_test.dart new file mode 100644 index 0000000000..b6f2a5e30f --- /dev/null +++ b/mobile/test/features/channels/media_viewer_page/video_viewer_test.dart @@ -0,0 +1,920 @@ +// Regression tests for MediaVideoViewerPage lifecycle fixes: +// +// F2r(a): VideoPlayerController is disposed when native initialisation fails. +// Before the fix, a PlatformException from initialize() unwound to the +// outer catch with controller.value still null — the cleanup teardown's +// `if (activeController != null)` guard silently skipped disposal, +// leaking the native player and its event subscription. +// +// F2r(b): A non-2xx error-body is cancelled (listen+cancel) rather than +// drained. Before the fix, `response.stream.drain()` waited for the +// server to close the stream — a stalled error body (e.g. a 403 on +// a slow connection) could block initializeVideo indefinitely, and +// there was no abort handle to cancel it. +// +// F2r(c): A VideoPlayerController created but never initialized (pending) +// is disposed when the widget is unmounted. Before the fix, the +// controller lived only in the async function's stack frame; a close +// arriving while initialize() was awaiting the initialized event left +// the native player allocated forever. +// +// F2r(d): createWithOptions() failure shows the error UI instead of leaving +// the viewer in an infinite loading state. video_player 2.11.1 +// creates _creatingCompleter before awaiting createWithOptions() and +// completes it only AFTER the await returns. If creation throws, +// _creatingCompleter is never completed and dispose() deadlocks waiting +// on it. The fix uses unawaited(dispose()) in the catch so the outer +// catch runs immediately and sets error.value. +// +// Transport: AbortableStreamedRequest sink must be closed before send(). +// Without it, IOClient.send() awaits stream.pipe(ioRequest) which +// blocks until the sink is closed — every download hangs indefinitely +// with the real http.Client. Two probes: a local loopback server that +// reads the full request body before replying (fails if sink is not +// closed), and a MockClient-based fake that calls request.finalize() +// for unit coverage. + +import 'dart:async'; +import 'dart:io'; + +import 'package:buzz/features/channels/media_viewer_page.dart'; +import 'package:buzz/shared/relay/media_auth.dart'; +import 'package:buzz/shared/relay/media_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:video_player_platform_interface/video_player_platform_interface.dart'; + +import '../../../helpers/widget_helpers.dart'; + +// ── Fakes ──────────────────────────────────────────────────────────────────── + +/// Minimal fake for path_provider so getTemporaryDirectory() works in tests. +/// Uses MockPlatformInterfaceMixin to bypass PlatformInterface.verify(). +class _FakePathProviderPlatform extends Fake + with MockPlatformInterfaceMixin + implements PathProviderPlatform { + @override + Future getTemporaryPath() async => + Directory.systemTemp.resolveSymbolicLinksSync(); +} + +/// Fake VideoPlayerPlatform that tracks `dispose` calls and optionally +/// forces native initialisation to fail with a PlatformException. +/// +/// Extends VideoPlayerPlatform directly (inheriting the platform token from the +/// super constructor) so PlatformInterface.verify() succeeds without needing +/// MockPlatformInterfaceMixin. +class _FakeVideoPlayerPlatform extends VideoPlayerPlatform { + final bool forceInitError; + final bool neverInitialize; + // When true, createWithOptions() itself throws a PlatformException. + // video_player 2.11.1 awaits createWithOptions() before completing + // _creatingCompleter (video_player.dart:587-590); if creation throws, + // _creatingCompleter is never completed and dispose() deadlocks waiting + // on it. This flag exercises the F2r(d) production fix. + final bool forceCreateError; + int disposeCallCount = 0; + int nextPlayerId = 0; + final Map> _streams = {}; + + _FakeVideoPlayerPlatform({ + this.forceInitError = false, + this.neverInitialize = false, + this.forceCreateError = false, + }); + + @override + Future init() async {} + + @override + Future createWithOptions(VideoCreationOptions options) async { + if (forceCreateError) { + throw PlatformException( + code: 'VideoError', + message: 'Fake native create failure', + ); + } + return create(options.dataSource); + } + + @override + Future create(DataSource dataSource) async { + final id = nextPlayerId++; + final controller = StreamController( + onListen: () { + // Emit the event/error only when the stream is first subscribed so that + // VideoPlayerController.initialize() is already listening. Emitting + // before the subscription means the event is dropped and initialize() + // hangs waiting for the initialized signal. + if (forceInitError) { + _streams[id]!.addError( + PlatformException( + code: 'VideoError', + message: 'Fake native init failure', + ), + ); + } else if (!neverInitialize) { + _streams[id]!.add( + VideoEvent( + eventType: VideoEventType.initialized, + size: const Size(100, 100), + duration: const Duration(seconds: 1), + ), + ); + } + // neverInitialize: no event emitted — initialize() hangs forever. + }, + ); + _streams[id] = controller; + return id; + } + + @override + Future dispose(int playerId) async { + disposeCallCount++; + // Record the dispose call and close the event stream. + // + // Note: an error injected here does NOT reach initialize()'s pending + // listener on the pinned video_player 2.11.1 path. dispose() cancels + // _eventSubscription (:687) before calling _videoPlayerPlatform.dispose() + // (:688), so any event emitted here goes to a closed listener. This fake + // records the disposal call and closes its stream; it does not settle the + // pending initialize() future. + final stream = _streams[playerId]; + if (stream != null) { + if (!stream.isClosed) { + stream.addError( + StateError('VideoPlayerController disposed before initialization'), + ); + } + await stream.close(); + } + } + + /// Return a minimal stand-in widget. VideoPlayerPlatform.buildViewWithOptions + /// delegates to this; without it every test that successfully initializes a + /// player throws UnimplementedError when the VideoPlayer widget renders. + @override + Widget buildView(int playerId) => const SizedBox.shrink(); + + @override + Stream videoEventsFor(int playerId) => _streams[playerId]!.stream; + + @override + Future play(int playerId) async {} + + @override + Future pause(int playerId) async {} + + @override + Future setLooping(int playerId, bool looping) async {} + + @override + Future setVolume(int playerId, double volume) async {} + + @override + Future seekTo(int playerId, Duration position) async {} + + @override + Future setPlaybackSpeed(int playerId, double speed) async {} + + @override + Future getPosition(int playerId) async => Duration.zero; + + @override + Future setMixWithOthers(bool mixWithOthers) async {} +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// A [VideoPlayerPlatform] that creates successfully (returns a real player ID +/// and emits a forceInitError event to trigger an init/play failure), but whose +/// [dispose()] throws a [PlatformException]. +/// +/// Used to verify that the viewer's `unawaited(dispose().catchError(...))` path +/// does NOT surface an uncaught async error when disposal fails after a +/// post-create load failure. +class _FailingDisposeVideoPlayerPlatform extends VideoPlayerPlatform { + int disposeCallCount = 0; + int nextPlayerId = 0; + final Map> _streams = {}; + + /// Completed when dispose() is first entered. Awaited inside + /// [WidgetTester.runAsync] with a bounded real-zone timeout; timers created + /// in that zone fire normally, unlike FakeAsync timers outside runAsync. + final Completer disposedCompleter = Completer(); + + @override + Future init() async {} + + @override + Future createWithOptions(VideoCreationOptions options) async { + return create(options.dataSource); + } + + @override + Future create(DataSource dataSource) async { + final id = nextPlayerId++; + final controller = StreamController( + onListen: () { + // Emit a PlatformException so initialize() throws, reaching the inner catch. + _streams[id]!.addError( + PlatformException( + code: 'VideoError', + message: 'Fake post-create init failure', + ), + ); + }, + ); + _streams[id] = controller; + return id; + } + + @override + Future dispose(int playerId) async { + disposeCallCount++; + if (!disposedCompleter.isCompleted) disposedCompleter.complete(); + // Throw to simulate a native disposal failure. + // The production catch(.catchError) must absorb this without propagating + // an uncaught async error while the viewer is showing its error UI. + throw PlatformException( + code: 'DisposalError', + message: 'Fake native disposal failure', + ); + } + + @override + Widget buildView(int playerId) => const SizedBox.shrink(); + + @override + Stream videoEventsFor(int playerId) => _streams[playerId]!.stream; + + @override + Future play(int playerId) async {} + + @override + Future pause(int playerId) async {} + + @override + Future setLooping(int playerId, bool looping) async {} + + @override + Future setVolume(int playerId, double volume) async {} + + @override + Future seekTo(int playerId, Duration position) async {} + + @override + Future setPlaybackSpeed(int playerId, double speed) async {} + + @override + Future getPosition(int playerId) async => Duration.zero; + + @override + Future setMixWithOthers(bool mixWithOthers) async {} +} + +/// Returns a [http.StreamedResponse] with the given [statusCode] whose body +/// stream is controlled by [bodyController]. The caller closes [bodyController] +/// to release a drain; leaving it open proves that the fix (listen+cancel) +/// completes without waiting for the stream to close. +http.StreamedResponse _streamedResponse( + int statusCode, + StreamController> bodyController, +) => http.StreamedResponse(bodyController.stream, statusCode); + +/// A no-op auth service (returns empty headers for any URL, including +/// non-relay URLs so the test media URL does not need a signed nsec). +MediaGetAuthService _noopAuth() => + MediaGetAuthService(baseUrl: 'https://relay.test', nsec: null); + +/// A fake [http.Client] that calls [request.finalize()] and drains the +/// request body before returning a response. If the request sink is not +/// closed, finalize() returns an open stream and the drain hangs — which is +/// exactly what the sink-fix prevents. +class _FinalizingFakeClient extends http.BaseClient { + final http.StreamedResponse Function() responseBuilder; + bool requestBodyDrained = false; + + _FinalizingFakeClient({required this.responseBuilder}); + + @override + Future send(http.BaseRequest request) async { + // Drain the finalized request body. If sink.close() was not called this + // stream never ends and the test times out — verifying the transport fix. + await request.finalize().drain(); + requestBodyDrained = true; + return responseBuilder(); + } +} + +/// A fake [http.Client] for viewer-path abort tests. +/// +/// `send()` signals arrival immediately, then suspends until the request's +/// `abortTrigger` completes. When the viewer's effect cleanup fires +/// `downloadRequestAbort.complete()`, that trigger arrives here and +/// `send()` throws [RequestAbortedException] — proving that unmounting +/// the widget closes the in-flight download through the actual viewer +/// abort-wiring path. +/// +/// The fake requires an [http.AbortableStreamedRequest] with a non-null +/// trigger. If the viewer's wiring is absent, `send()` throws [StateError], +/// which the viewer catches at its outer `catch (loadError)` boundary. The +/// test then fails at the [abortObservedCompleter] deadline because the abort +/// is never observed — not immediately, but after the deadline expires. +/// +/// No drain: this fake's only job is the abort-trigger chain. Sink-close +/// correctness is covered separately by [_FinalizingFakeClient]. +class _StallingAbortableClient extends http.BaseClient { + final Completer requestArrivedCompleter = Completer(); + + /// Completed when `abortObserved` is set; use with a deadline instead of sleeping. + final Completer abortObservedCompleter = Completer(); + bool abortObserved = false; + + @override + Future send(http.BaseRequest request) async { + if (!requestArrivedCompleter.isCompleted) { + requestArrivedCompleter.complete(); + } + // Require an abortable request with a non-null trigger. If the viewer's + // AbortableStreamedRequest wiring or the abortTrigger: parameter is absent, + // fail immediately — do NOT treat missing wiring as a successful abort. + if (request case http.AbortableStreamedRequest(:final abortTrigger?)) { + await abortTrigger; + abortObserved = true; + if (!abortObservedCompleter.isCompleted) { + abortObservedCompleter.complete(); + } + throw http.RequestAbortedException(request.url); + } + throw StateError( + '_StallingAbortableClient: expected AbortableStreamedRequest with ' + 'non-null abortTrigger; got ${request.runtimeType}. ' + 'The viewer abort wiring is absent.', + ); + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + PathProviderPlatform.instance = _FakePathProviderPlatform(); + }); + + // Transport: request sink must be closed before send(). + // + // Red-with-old-code: before the `unawaited(request.sink.close())` fix, + // _FinalizingFakeClient.send() drained an open stream and the test timed + // out. With the fix the drain completes immediately, the download succeeds, + // and the video controller initializes. + testWidgets( + 'Transport: request sink is closed before send() — fake drain probe', + (tester) async { + final fakePlayer = _FakeVideoPlayerPlatform(); + VideoPlayerPlatform.instance = fakePlayer; + + final fakeClient = _FinalizingFakeClient( + responseBuilder: () => + http.StreamedResponse(Stream.value([0, 1, 2, 3]), 200), + ); + addTearDown(fakeClient.close); + + await tester.runAsync(() async { + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [ + mediaGetAuthServiceProvider.overrideWithValue(_noopAuth()), + mediaHttpClientProvider.overrideWithValue(fakeClient), + ], + child: const MediaVideoViewerPage( + videoUrl: 'https://relay.test/media/abc.mp4', + ), + ), + ); + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pump(); + + expect( + fakeClient.requestBodyDrained, + isTrue, + reason: 'request sink must be closed so send() can finalize the body', + ); + }, + ); + + // Transport: request sink must be closed before send(). + // + // Real-IO loopback probes (TestWidgetsFlutterBinding intercepts HttpClient + // within this suite) live in video_viewer_transport_test.dart, which uses + // plain test() without TestWidgetsFlutterBinding. The fake drain probe + // below covers the same contract without the binding conflict. + // + // + // Red-with-old-code: before the fix the `localController` was created but + // only published to `controller.value` after a successful initialize()+play(). + // On error the outer `catch (loadError)` ran without ever calling + // `localController.dispose()`. With forceInitError=true the fake emits a + // PlatformException; `initialize()` throws; the new inner catch calls + // `dispose()` before rethrowing. disposeCallCount >= 1 verifies it. + testWidgets('F2r(a): VideoPlayerController is disposed when native init fails', ( + tester, + ) async { + final fakePlayer = _FakeVideoPlayerPlatform(forceInitError: true); + VideoPlayerPlatform.instance = fakePlayer; + + // 200-ok response with a tiny immediate body so the download phase + // completes and initializeVideo() reaches the VideoPlayerController.file() + // path. The _FinalizingFakeClient drains the request body, which proves + // the sink is closed (if not, the drain hangs and the test times out). + final fakeClient = _FinalizingFakeClient( + responseBuilder: () => + http.StreamedResponse(Stream.value([0, 1, 2, 3]), 200), + ); + addTearDown(fakeClient.close); + + await tester.runAsync(() async { + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [ + mediaGetAuthServiceProvider.overrideWithValue(_noopAuth()), + mediaHttpClientProvider.overrideWithValue(fakeClient), + ], + child: const MediaVideoViewerPage( + videoUrl: 'https://relay.test/media/abc.mp4', + ), + ), + ); + // Give the initializeVideo() async chain time to complete: HTTP response, + // file write, VideoPlayerController.initialize(), and dispose(). + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pump(); + + // The fake must have recorded at least one dispose() call, confirming + // the native player was released even on an initialisation failure. + expect( + fakePlayer.disposeCallCount, + greaterThanOrEqualTo(1), + reason: 'VideoPlayerController must be disposed when initialize() throws', + ); + }); + + // F2r(b): close-during-error-body must cancel the stream and show the + // error UI while the body is still open. + // + // Red-with-old-code: before the fix a 403 response body was consumed via + // `response.stream.drain()`, which suspends until the upstream closes + // the stream. With the fix, `_cancelVideoResponse(response)` subscribes and + // immediately cancels. + // + // Discriminating assertion: (1) the body stream's onCancel fires while the + // body is still open (never would with drain()), and (2) the error UI is + // visible while the body remains open. Restoring drain() breaks both. + testWidgets('F2r(b): non-2xx error body is cancelled, not drained', ( + tester, + ) async { + final fakePlayer = _FakeVideoPlayerPlatform(); + VideoPlayerPlatform.instance = fakePlayer; + + // Body stream that NEVER closes — simulates a slow/stalled server. + // drain() would block here indefinitely; _cancelVideoResponse completes + // immediately by subscribing and cancelling. + // + // The Completer fires as soon as the stream's onCancel callback runs, + // giving the test a bounded completion signal instead of a fixed sleep. + // It is the cancellation of the body (not settling) that proves the fix: + // the assertions below check that (a) the cancel fires while the body is + // still open, and (b) the error UI is visible at that moment. + final cancelledCompleter = Completer(); + var bodyStreamCancelled = false; + final stalledBody = StreamController>( + onCancel: () { + bodyStreamCancelled = true; + if (!cancelledCompleter.isCompleted) cancelledCompleter.complete(); + }, + ); + addTearDown(stalledBody.close); + + final fakeClient = _FinalizingFakeClient( + responseBuilder: () => _streamedResponse(403, stalledBody), + ); + addTearDown(fakeClient.close); + + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [ + mediaGetAuthServiceProvider.overrideWithValue(_noopAuth()), + mediaHttpClientProvider.overrideWithValue(fakeClient), + ], + child: const MediaVideoViewerPage( + videoUrl: 'https://relay.test/media/abc.mp4', + ), + ), + ); + + // Wait for the body-cancellation signal rather than a fixed sleep. + // disableAnimations: true stops BuzzLoadingIndicator from repeating so + // pumpAndSettle converges once the error state is set. + // With drain(), cancelledCompleter never completes and this times out. + await tester.runAsync( + () => cancelledCompleter.future.timeout( + const Duration(seconds: 5), + onTimeout: () => throw TimeoutException( + 'body-cancellation signal not received within 5 s', + ), + ), + ); + await tester.pumpAndSettle(); + + // (1) The body stream's onCancel must have fired — confirming listen+cancel + // was used, not drain(). With drain(), onCancel fires only when the + // whole drain completes (which never happens here). + expect( + bodyStreamCancelled, + isTrue, + reason: + 'error-body stream must be cancelled (listen+cancel), not drained', + ); + + // (2) The error UI must be visible while the body stream is still open + // (stalledBody was never closed). _MediaLoadFailure shows this text + // when error.value is set — which only happens after _cancelVideoResponse + // completes and the HttpException propagates to the outer catch. + // With drain(), error.value is never set (drain hangs), so this + // assertion fails. + expect( + find.text('Failed to load video'), + findsOneWidget, + reason: 'error UI must be visible while the stalled body is still open', + ); + + // No controller is created before a 403 response. + expect( + fakePlayer.disposeCallCount, + 0, + reason: 'No controller is created before a 403 response', + ); + + // (3) Explicitly unmount the viewer and verify no disposal fires from the + // teardown either — no controller was ever created. + // Pass the same overrides so Riverpod's debug assertion + // (_debugOverridesLength == overrides.length) does not fire. + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [ + mediaGetAuthServiceProvider.overrideWithValue(_noopAuth()), + mediaHttpClientProvider.overrideWithValue(fakeClient), + ], + child: const SizedBox.shrink(), + ), + ); + await tester.pumpAndSettle(); + expect( + fakePlayer.disposeCallCount, + 0, + reason: 'Unmounting after 403 must not dispose a non-existent controller', + ); + }); + + // F2r(c): VideoPlayerController created but never initialized must be + // disposed when the widget is unmounted. + // + // Scenario: download succeeds, VideoPlayerController.file() is constructed + // and registered in pendingController, but initialize() hangs forever (the + // fake never emits an initialized event). Unmounting fires the effect + // cleanup which must dispose the pending controller via pendingController. + // + // Red-with-old-code: before the pendingController ref, localController lived + // only in the async function's stack frame; the effect cleanup read only + // controller.value (null until init completes) and disposed nothing — the + // native player was leaked. + testWidgets( + 'F2r(c): controller created but never initialized is disposed on unmount', + (tester) async { + final fakePlayer = _FakeVideoPlayerPlatform(neverInitialize: true); + VideoPlayerPlatform.instance = fakePlayer; + + final fakeClient = _FinalizingFakeClient( + responseBuilder: () => + http.StreamedResponse(Stream.value([0, 1, 2, 3]), 200), + ); + addTearDown(fakeClient.close); + + await tester.runAsync(() async { + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [ + mediaGetAuthServiceProvider.overrideWithValue(_noopAuth()), + mediaHttpClientProvider.overrideWithValue(fakeClient), + ], + child: const MediaVideoViewerPage( + videoUrl: 'https://relay.test/media/abc.mp4', + ), + ), + ); + // Allow enough time for the download to complete and for + // VideoPlayerController.file() to be constructed, but NOT long + // enough for initialize() to complete (it never will). + await Future.delayed(const Duration(milliseconds: 300)); + + // Now unmount — this triggers the effect cleanup with the pending + // controller still in pendingController.value (never reached play()). + // Pass the same overrides so Riverpod's debug assertion + // (_debugOverridesLength == overrides.length) does not fire. + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [ + mediaGetAuthServiceProvider.overrideWithValue(_noopAuth()), + mediaHttpClientProvider.overrideWithValue(fakeClient), + ], + child: const SizedBox.shrink(), + ), + ); + await Future.delayed(const Duration(milliseconds: 100)); + }); + await tester.pump(); + + // The pending controller must have been disposed by the effect cleanup, + // even though initialize() never returned. + expect( + fakePlayer.disposeCallCount, + greaterThanOrEqualTo(1), + reason: + 'pendingController must be disposed on unmount even if init never completes', + ); + }, + ); + + // F2r(d): createWithOptions() failure must show the error UI, not leave + // the viewer in an infinite loading state. + // + // Scenario: the platform plugin's createWithOptions() itself throws a + // PlatformException before returning a player ID. video_player 2.11.1 + // creates _creatingCompleter at initialize():546 and completes it only on + // the line AFTER await createWithOptions() (:587-590). If creation throws, + // _creatingCompleter is never completed, and dispose() awaits it at :682-683. + // The old code `await localController.dispose()` in the catch block therefore + // deadlocks: the outer catch never runs, error.value is never set, and the + // viewer remains on the loading screen. + // + // Fix: `unawaited(localController.dispose().catchError(...))` in the catch + // rethrows immediately so the outer catch sets error.value and shows the UI. + // + // Red-with-old-code: the old `await localController.dispose()` never returns + // (dispose() waits on the uncompleted _creatingCompleter); the 300 ms window + // ends with error.value still unset and the error UI absent — the assertion + // fails. With `unawaited(dispose())` the outer catch runs immediately and + // sets error.value; the 300 ms window is enough for the fix to take effect. + // + // Note: create itself fails here, so _creatingCompleter is never completed + // and the unawaited dispose() stalls at the same wait — this fix bypasses + // the deadlock for the outer catch, but does not release the native player. + // No dispose count is claimed for this case. + testWidgets( + 'F2r(d): createWithOptions() failure shows error UI (not infinite spinner)', + (tester) async { + final fakePlayer = _FakeVideoPlayerPlatform(forceCreateError: true); + VideoPlayerPlatform.instance = fakePlayer; + + final fakeClient = _FinalizingFakeClient( + responseBuilder: () => + http.StreamedResponse(Stream.value([0, 1, 2, 3]), 200), + ); + addTearDown(fakeClient.close); + + await tester.runAsync(() async { + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [ + mediaGetAuthServiceProvider.overrideWithValue(_noopAuth()), + mediaHttpClientProvider.overrideWithValue(fakeClient), + ], + child: const MediaVideoViewerPage( + videoUrl: 'https://relay.test/media/abc.mp4', + ), + ), + ); + // Allow the download to complete, createWithOptions() to throw, and + // the outer catch to set error.value. With the old `await dispose()` + // the outer catch is blocked — `error.value` is never set and + // `find.text('Failed to load video')` fails. + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pumpAndSettle(); + + // The error UI must be visible: unawaited dispose + rethrow lets the + // outer catch set error.value and show _MediaLoadFailure. + // With the old `await dispose()` the viewer stalls and this fails. + expect( + find.text('Failed to load video'), + findsOneWidget, + reason: + 'createWithOptions() failure must show error UI, not infinite spinner', + ); + }, + ); + + // Detached disposal error handling: a successfully-created controller whose + // init fails and whose dispose() ALSO throws must not surface an uncaught + // async error. The unawaited(dispose().catchError(...)) path in the inner + // catch must absorb the disposal exception with logging. + // + // Red-with-old-code (before the .catchError addition): the detached future + // throws PlatformException unhandled; the Flutter test binding's own + // FlutterError.onError captures it and fails the test. With .catchError + // the error is absorbed before reaching the binding's handler. + // + // Note: no FlutterError.onError override is needed here. testWidgets + // automatically fails the test if any uncaught Flutter error reaches the + // binding's handler — the test passing IS the assertion that no uncaught + // error occurred. + testWidgets( + 'F2r(d)+: post-create disposal failure shows error UI and no uncaught error', + (tester) async { + final fakeClient = _FinalizingFakeClient( + responseBuilder: () => + http.StreamedResponse(Stream.value([0, 1, 2, 3]), 200), + ); + addTearDown(fakeClient.close); + + // Construct the fake and its completer inside runAsync so that the + // bounded await below is registered in the real scheduler zone, not + // FakeAsync. A Duration-based timeout or future created outside runAsync + // becomes a FakeAsync timer; the binding never auto-advances fake time in + // testWidgets, so it hangs to the 30 s outer runner timeout instead of + // failing promptly. Inside runAsync, timers are dispatched to the real + // event loop and fire normally. + // + // Zone ownership of the completion: the viewer mounts here, so + // initializeVideo() starts in the real zone. The inner catch calls + // unawaited(localController.dispose().catchError(...)) also in the real + // zone. _FailingDisposeVideoPlayerPlatform.dispose() runs synchronously + // inside that detached future, completing disposedCompleter before any + // suspension. The await below (also in the real zone) therefore resolves + // as soon as the microtask queue drains the detached disposal future. + // + // Removal check: removing only the unawaited disposal call leaves + // disposedCompleter never completed; the 5 s timeout fires, failing the + // test immediately and deterministically. + await tester.runAsync(() async { + final fakePlayer = _FailingDisposeVideoPlayerPlatform(); + VideoPlayerPlatform.instance = fakePlayer; + + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [ + mediaGetAuthServiceProvider.overrideWithValue(_noopAuth()), + mediaHttpClientProvider.overrideWithValue(fakeClient), + ], + child: const MediaVideoViewerPage( + videoUrl: 'https://relay.test/media/abc.mp4', + ), + ), + ); + + // Wait for the disposal path to be entered, bounded by a real-zone + // timeout. The viewer downloads the body, creates the player, emits a + // PlatformException from the event stream, enters the inner catch, and + // calls unawaited(dispose().catchError(...)). dispose() completes + // disposedCompleter synchronously at its entry point before throwing. + await fakePlayer.disposedCompleter.future.timeout( + const Duration(seconds: 5), + onTimeout: () => throw TimeoutException( + 'dispose() was not entered within 5 s ' + '— the unawaited disposal path may have been removed', + ), + ); + + // Pump to flush the error state set in the outer catch after the + // detached disposal starts. + await tester.pumpAndSettle(); + + // Error UI must appear — disposal failure must not block the outer catch. + expect( + find.text('Failed to load video'), + findsOneWidget, + reason: 'post-create disposal failure must still show error UI', + ); + // dispose() must have been called — confirms the unawaited disposal path ran. + expect( + fakePlayer.disposeCallCount, + greaterThanOrEqualTo(1), + reason: 'dispose() must have been called on the failing player', + ); + // The test passing without a framework error IS the assertion that + // the disposal PlatformException was absorbed by .catchError and did + // not reach the binding's uncaught-error handler. + }); + }, + ); + + // Viewer-path abort: unmounting the widget while the download is in-flight + // must abort the HTTP request through the viewer's own wiring. + // + // The viewer creates an AbortableStreamedRequest with abortTrigger wired to + // downloadRequestAbort.value (a Completer). The effect cleanup calls + // activeRequestAbort.complete() on unmount, which fires abortTrigger. + // + // Red-with-reverted-wiring: removing only `activeRequestAbort.complete()` + // from the cleanup leaves the trigger pending and abortObserved stays false + // at the deadline — the abortObservedCompleter times out. + // Deleting the whole AbortableStreamedRequest / abortTrigger wiring causes + // the fake to throw StateError from send(); the viewer catches it as a load + // error, so abortObserved is never set and the test fails at the deadline. + testWidgets( + 'Viewer abort-path: unmount fires abortTrigger and cancels in-flight download', + (tester) async { + final fakePlayer = _FakeVideoPlayerPlatform(); + VideoPlayerPlatform.instance = fakePlayer; + + final stallingClient = _StallingAbortableClient(); + addTearDown(stallingClient.close); + + await tester.runAsync(() async { + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [ + mediaGetAuthServiceProvider.overrideWithValue(_noopAuth()), + mediaHttpClientProvider.overrideWithValue(stallingClient), + ], + child: const MediaVideoViewerPage( + videoUrl: 'https://relay.test/media/abort-test.mp4', + ), + ), + ); + + // Wait for the viewer to start the download and reach the stall point. + await stallingClient.requestArrivedCompleter.future.timeout( + const Duration(seconds: 5), + onTimeout: () => throw TimeoutException( + 'Viewer did not start download within 5 s', + ), + ); + + // Verify abort has NOT fired yet (before unmount). + expect( + stallingClient.abortObserved, + isFalse, + reason: 'abort must not fire before unmount', + ); + + // Now unmount — this fires the effect cleanup which calls + // activeRequestAbort.complete(), completing abortTrigger. + // Pass the same overrides so Riverpod's debug assertion does not fire. + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [ + mediaGetAuthServiceProvider.overrideWithValue(_noopAuth()), + mediaHttpClientProvider.overrideWithValue(stallingClient), + ], + child: const SizedBox.shrink(), + ), + ); + // Pump once to flush the effect cleanup microtasks that fire during + // the widget-tree disposal (useEffect cleanup in Flutter hooks runs + // synchronously in the pumpWidget call above, but the Completer + // completion and the abortTrigger await chain resolve on subsequent + // microtask turns — yield here so they settle before the deadline). + await tester.pump(); + + // Wait for the abort to be observed with a deadline — no sleep. + // Deleting activeRequestAbort.complete() in cleanup → times out here. + await stallingClient.abortObservedCompleter.future.timeout( + const Duration(seconds: 5), + onTimeout: () => throw TimeoutException( + 'Abort was not observed within 5 s after unmount', + ), + ); + }); + await tester.pump(); + + // The fake must have received the abort — proving that the viewer's + // effect cleanup correctly wired the Completer to the AbortableStreamedRequest. + expect( + stallingClient.abortObserved, + isTrue, + reason: + 'viewer unmount must complete abortTrigger and cancel the in-flight download', + ); + }, + ); +} diff --git a/mobile/test/features/channels/media_viewer_page/video_viewer_transport_test.dart b/mobile/test/features/channels/media_viewer_page/video_viewer_transport_test.dart new file mode 100644 index 0000000000..d82f3dce8f --- /dev/null +++ b/mobile/test/features/channels/media_viewer_page/video_viewer_transport_test.dart @@ -0,0 +1,169 @@ +// Real-IO transport probes for MediaVideoViewerPage. +// +// These tests exercise the actual IOClient (dart:io) transport path for +// AbortableStreamedRequest. They are intentionally in a SEPARATE file +// from video_viewer_test.dart because TestWidgetsFlutterBinding.ensureInitialized() +// in the main test file installs a suite-wide HttpClient override that returns +// status 400 for ALL requests — including plain test() calls in the same suite. +// By isolating these here, the real loopback HttpServer can be reached. +// +// Transport probe #1: request sink must be closed before send(). +// A local loopback server reads the full request body before replying. +// Without the `unawaited(request.sink.close())` fix, IOClient.send() blocks +// at `stream.pipe(ioRequest)` forever — test times out. +// +// Transport probe #2: abort trigger cancels an in-flight download. +// The server holds the response open via a teardown-controlled release gate +// so the abort fires against a genuinely in-flight transfer rather than +// racing a completed response. After server confirms request arrival, +// the abort is triggered; send() must throw the typed +// RequestAbortedException within a bounded deadline. + +import 'dart:async'; +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:http/io_client.dart' show IOClient; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'Transport: request sink is closed — real IO loopback probe (success)', + () async { + final videoBytes = [0, 1, 2, 3]; + + // Start a local HTTP server that reads the full request body BEFORE + // sending the response. If the sink is not closed, the drain hangs. + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + + server.listen((req) async { + await req.drain(); // hangs if sink was not closed + req.response + ..statusCode = 200 + ..headers.contentType = ContentType('video', 'mp4') + ..contentLength = videoBytes.length + ..add(videoBytes); + await req.response.close(); + }); + + final serverUrl = + 'http://${server.address.host}:${server.port}/video.mp4'; + final client = IOClient( + HttpClient()..idleTimeout = const Duration(milliseconds: 1), + ); + addTearDown(client.close); + + final requestAbort = Completer(); + final request = http.AbortableStreamedRequest( + 'GET', + Uri.parse(serverUrl), + abortTrigger: requestAbort.future, + ); + // THE FIX: close the sink before send so the pipe completes. + unawaited(request.sink.close()); + + final response = await client + .send(request) + .timeout( + const Duration(seconds: 5), + onTimeout: () => + throw TimeoutException('send() did not complete within 5 s'), + ); + + expect( + response.statusCode, + 200, + reason: 'sink closed → server receives full request → replies 200', + ); + await response.stream.drain().timeout( + const Duration(seconds: 5), + onTimeout: () => throw TimeoutException( + 'response drain did not complete within 5 s', + ), + ); + }, + ); + + test( + 'Transport: abort trigger cancels an in-flight download — real IO loopback', + () async { + // The server signals that the request has arrived (so the abort fires + // AFTER the connection is established, not before). + final requestArrivedCompleter = Completer(); + + // Teardown-controlled gate: the server handler awaits this before + // calling response.close(), so the response body stays open until the + // test explicitly releases it (on success) or teardown releases it (on + // failure). Without this gate, response.close() would send an empty 200 + // immediately and the abort would race against a completed response — + // making the probe scheduling-sensitive rather than a controlled + // in-flight cancellation. + final releaseResponse = Completer(); + + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + addTearDown(() { + if (!releaseResponse.isCompleted) releaseResponse.complete(); + }); + + server.listen((req) async { + await req.drain(); + // Signal that the server has received the request, then hold the + // response open until the test releases it or teardown fires. + if (!requestArrivedCompleter.isCompleted) { + requestArrivedCompleter.complete(); + } + // Await the teardown-controlled gate before attempting to close. + // After an abort the client may have already torn down the connection, + // so close() may throw — caught and discarded here. + await releaseResponse.future; + try { + await req.response.close(); + } catch (_) { + // Connection may already be torn down by the client abort — expected. + } + }); + + final serverUrl = + 'http://${server.address.host}:${server.port}/video.mp4'; + final client = IOClient( + HttpClient()..idleTimeout = const Duration(milliseconds: 1), + ); + addTearDown(client.close); + + final requestAbort = Completer(); + final request = http.AbortableStreamedRequest( + 'GET', + Uri.parse(serverUrl), + abortTrigger: requestAbort.future, + ); + unawaited(request.sink.close()); + + // Start the request, wait for server-arrival confirmation, then abort. + final sendFuture = client.send(request); + // Bounded wait: if the server doesn't see the request within 5s, fail. + await requestArrivedCompleter.future.timeout( + const Duration(seconds: 5), + onTimeout: () => throw TimeoutException( + 'Loopback server did not receive request within 5 s', + ), + ); + requestAbort.complete(); + + // send() must throw RequestAbortedException within a bounded deadline. + // The typed assertion distinguishes an abort from any other exception. + await expectLater( + sendFuture.timeout(const Duration(seconds: 5)), + throwsA(isA()), + reason: 'abort must cause send() to throw RequestAbortedException', + ); + + // Release the server handler so it can exit cleanly. The abort may + // have already torn down the connection, so close() in the handler may + // throw and be discarded. Teardown also releases this gate; this is + // belt-and-suspenders cleanup for the success path. + if (!releaseResponse.isCompleted) releaseResponse.complete(); + }, + ); +} diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 8fe47e8803..150ccd4290 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -40,15 +40,18 @@ Widget _testable( ], child: MaterialApp( theme: AppTheme.light(), - home: Builder( - builder: (context) => MediaQuery( - data: MediaQuery.of( - context, - ).copyWith(disableAnimations: disableAnimations), - // The app states its code style here, above the navigator. - child: AppMarkdownTheme(child: Scaffold(body: child)), - ), + // Use MaterialApp.builder so the MediaQuery override (including + // disableAnimations) applies to every pushed route, not just the + // home scaffold. Navigator-pushed routes (e.g. MediaVideoViewerPage) + // skip a home-level Builder wrapper entirely. + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: disableAnimations), + // AppMarkdownTheme must wrap all routes that render message content. + child: AppMarkdownTheme(child: child!), ), + home: Scaffold(body: child), ), ); } @@ -2216,6 +2219,7 @@ Photos ], ], ), + disableAnimations: true, ), ); await tester.pumpAndSettle(); @@ -2284,6 +2288,7 @@ Photos ], ], ), + disableAnimations: true, ), ); await tester.pumpAndSettle(); diff --git a/mobile/test/helpers/widget_helpers.dart b/mobile/test/helpers/widget_helpers.dart index b7f119db87..11b4b42141 100644 --- a/mobile/test/helpers/widget_helpers.dart +++ b/mobile/test/helpers/widget_helpers.dart @@ -7,12 +7,20 @@ class WidgetHelpers { static Widget testable({ required Widget child, List overrides = const [], + bool disableAnimations = false, }) { return ProviderScope( overrides: overrides, child: MaterialApp( theme: AppTheme.light(), - home: Scaffold(body: child), + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: disableAnimations), + child: Scaffold(body: child), + ), + ), ), ); } diff --git a/mobile/test/shared/relay/media_image_test.dart b/mobile/test/shared/relay/media_image_test.dart index 4f43ec4770..e3626cf892 100644 --- a/mobile/test/shared/relay/media_image_test.dart +++ b/mobile/test/shared/relay/media_image_test.dart @@ -31,30 +31,35 @@ void main() { PaintingBinding.instance.imageCache.clearLiveImages(); }); - group('MediaGetAuthService memoization', () { - test('repeated calls return byte-identical headers', () { + group('MediaGetAuthService mint-per-request', () { + test('consecutive calls return distinct headers (mint-per-request)', () { final nsec = nostr.Keys.generate().nsec; final auth = _auth(nsec: nsec); final first = auth.headersFor(_mediaUrl); final second = auth.headersFor(_mediaUrl); expect(first, isNotEmpty); - expect(identical(first, second), isTrue); + // With lifetime == margin == 60s, refreshAt == signedAt, so the cache + // never hits: each call produces a fresh proof. + expect(identical(first, second), isFalse); + expect(second['Authorization'], isNot(first['Authorization'])); }); - test('re-signs only at the refresh margin before expiry', () { + test('each call produces a fresh token regardless of elapsed time', () { final nsec = nostr.Keys.generate().nsec; var current = DateTime.utc(2026, 7, 21, 12); final auth = _auth(nsec: nsec, now: () => current); final first = auth.headersFor(_mediaUrl); - // 600s lifetime - 60s margin = re-sign boundary at +540s. - current = current.add(const Duration(seconds: 539)); - expect(identical(auth.headersFor(_mediaUrl), first), isTrue); + // No elapsed time: still mints fresh. + final atZero = auth.headersFor(_mediaUrl); + expect(identical(atZero, first), isFalse); + expect(atZero['Authorization'], isNot(first['Authorization'])); - current = current.add(const Duration(seconds: 2)); - final refreshed = auth.headersFor(_mediaUrl); - expect(identical(refreshed, first), isFalse); - expect(refreshed['Authorization'], isNot(first['Authorization'])); + // Advance clock: still mints fresh. + current = current.add(const Duration(seconds: 30)); + final at30 = auth.headersFor(_mediaUrl); + expect(identical(at30, first), isFalse); + expect(at30['Authorization'], isNot(first['Authorization'])); }); test('non-relay URLs get no headers even with a key', () { diff --git a/mobile/test/shared/relay/media_upload_test.dart b/mobile/test/shared/relay/media_upload_test.dart index e9dbed302e..dd72efe73e 100644 --- a/mobile/test/shared/relay/media_upload_test.dart +++ b/mobile/test/shared/relay/media_upload_test.dart @@ -292,7 +292,7 @@ void main() { expect(authEvent['content'], 'Get buzz-media'); expect(authEvent['tags'], contains(equals(['t', 'get']))); expect(authEvent['tags'], contains(equals(['server', 'relay.example']))); - expect(authEvent['tags'], contains(equals(['expiration', '1700000600']))); + expect(authEvent['tags'], contains(equals(['expiration', '1700000060']))); }); test('does not sign non-relay or non-media URLs', () { @@ -419,7 +419,7 @@ void main() { equals(['x', capturedRequest!.headers['X-SHA-256']!]), ), ); - expect(tags, anyElement(equals(['expiration', '1700000300']))); + expect(tags, anyElement(equals(['expiration', '1700000060']))); expect( tags, anyElement(equals(['server', 'relay.example:8443'])), diff --git a/scripts/test-video-upload.sh b/scripts/test-video-upload.sh index 09c4ffed9f..90e632428c 100755 --- a/scripts/test-video-upload.sh +++ b/scripts/test-video-upload.sh @@ -55,14 +55,15 @@ echo "Test MP4: ${FILE_SIZE} bytes, sha256=${SHA256:0:16}..." echo "" # ── Helper: build Blossom auth header ────────────────────────────────────────── -# Creates a kind:24242 event with t=upload, x=, expiration=+5min. +# Creates a kind:24242 event with t=upload, x=, expiration=+60s, server=. blossom_auth() { local sha256="$1" - local now exp auth_event auth_b64 + local now exp server auth_event auth_b64 now=$(date +%s) - exp=$((now + 300)) + exp=$((now + 60)) + server=$(echo "$RELAY_URL" | sed -E 's|^https?://||' | cut -d'/' -f1) auth_event=$(nak event \ --sec "$NSEC" \ @@ -71,6 +72,7 @@ blossom_auth() { -t t=upload \ -t "x=$sha256" \ -t "expiration=$exp" \ + -t "server=$server" \ 2>/dev/null) auth_b64=$(echo -n "$auth_event" | base64 | tr -d '\n') From 52c4c08c9ef349f2bb994ca40e98faa0f44209ea Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 28 Sep 2026 14:48:16 -0400 Subject: [PATCH 07/12] fix(sidebar): converge stale-at-open state across devices (sections/sort/stars/mutes) (#7805) Adds bounded-backoff recovery reads to all four sidebar sync hooks (sections, sort, stars and mutes). A stale relay read at app open now converges without a user edit or a reconnect. ## What this does **Shared recovery hook (`useStaleReaderRecovery`).** All four hooks use one retry effect. It reads immediately on mount, then at 5, 10, 30 and 60 s, staying at 60 s after that. A change of `visibilityState` to `visible` triggers an immediate read. Reads are single-flight. The effect restarts whenever `pubkey` or `relayUrl` changes, so a hook that stays mounted while switching relays drops the old relay's in-flight reads. **Guards that stop a recovery read from rolling back a local edit:** 1. **Skip the read while an edit is pending.** If an edit is pending when a tick starts, the tick skips its read and just schedules the next one. Without this, the read could return the pre-edit blob after the edit published and pending cleared. At that point neither apply-time guard could reject it. 2. **Pending check inside the state updater.** `hasPending()` and the effect lifetime are re-read inside `setStore(prev => ...)`. That way the check runs when React executes the updater, not when the response arrives. 3. **Local-mutation revision.** Each hook counts its local edits. The count is snapshotted when a read starts and checked again at apply time. If an edit landed while the read was in flight, the response is discarded. 4. **Head advanced only after a successful cache write.** A failed `write*Store()` leaves the same head retryable on the next tick. **Ownership-aware pending clear.** Each sync manager's `doPublish(store)` now clears `pendingStore` only while it still holds that same `store`, on both the identical-payload early return and the success path. Before, an older publish that finished after a newer edit had replaced `pendingStore` wiped the newer edit's pending marker, so a recovery read before the newer debounce could apply the older relay head over it. `destroy()` still clears unconditionally. ## Scope Reader-side recovery plus the ownership-aware pending clear. No outboxes, arbitration or replay changes. It applies to `useChannelSections`, `useChannelSortPreference`, `useChannelStars` and `useChannelMutes`. Related: [#7806](https://github.com/block/buzz/pull/7806) --------- Signed-off-by: Will Pfleger Signed-off-by: Duncan Co-authored-by: Duncan --- .../features/sidebar/lib/channelMutesSync.ts | 73 +- .../lib/channelSectionsHelpers.test.mjs | 79 --- .../sidebar/lib/channelSectionsHelpers.ts | 21 - .../lib/channelSectionsStorage.test.mjs | 224 ------ .../sidebar/lib/channelSectionsStorage.ts | 130 +--- .../sidebar/lib/channelSectionsSync.test.mjs | 282 -------- .../sidebar/lib/channelSectionsSync.ts | 361 +++------- .../lib/channelSortPreference.test.mjs | 117 ---- .../sidebar/lib/channelSortPreference.ts | 81 +-- .../sidebar/lib/channelSortSync.test.mjs | 262 ------- .../features/sidebar/lib/channelSortSync.ts | 286 +------- .../features/sidebar/lib/channelStarsSync.ts | 73 +- .../sidebar/lib/sidebarLaneHooks.test.mjs | 572 ++++++++++++++++ .../sidebar/lib/sidebarLaneReconciler.ts | 387 +++++++++++ .../features/sidebar/lib/sidebarLaneStore.ts | 158 +++++ .../sidebar/lib/sidebarLaneSync.test.mjs | 643 ++++++++++++++++++ .../sidebar/lib/sidebarLwwMap.test.mjs | 147 ++++ .../src/features/sidebar/lib/sidebarLwwMap.ts | 203 ++++++ .../features/sidebar/lib/useChannelMutes.ts | 38 +- .../sidebar/lib/useChannelSections.test.mjs | 78 --- .../sidebar/lib/useChannelSections.ts | 386 +++-------- .../sidebar/lib/useChannelSortPreference.ts | 187 +---- .../features/sidebar/lib/useChannelStars.ts | 38 +- .../sidebar/lib/useStaleReaderRecovery.ts | 158 +++++ .../src/features/sidebar/ui/AppSidebar.tsx | 1 - .../api/relayClientPublishRejection.test.mjs | 23 + desktop/src/shared/api/relayClientSession.ts | 2 + desktop/src/shared/api/relayEventPublisher.ts | 22 +- desktop/tests/e2e/channel-sort.spec.ts | 7 +- 29 files changed, 2754 insertions(+), 2285 deletions(-) delete mode 100644 desktop/src/features/sidebar/lib/channelSectionsHelpers.test.mjs delete mode 100644 desktop/src/features/sidebar/lib/channelSectionsHelpers.ts delete mode 100644 desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs delete mode 100644 desktop/src/features/sidebar/lib/channelSortSync.test.mjs create mode 100644 desktop/src/features/sidebar/lib/sidebarLaneHooks.test.mjs create mode 100644 desktop/src/features/sidebar/lib/sidebarLaneReconciler.ts create mode 100644 desktop/src/features/sidebar/lib/sidebarLaneStore.ts create mode 100644 desktop/src/features/sidebar/lib/sidebarLaneSync.test.mjs create mode 100644 desktop/src/features/sidebar/lib/sidebarLwwMap.test.mjs create mode 100644 desktop/src/features/sidebar/lib/sidebarLwwMap.ts delete mode 100644 desktop/src/features/sidebar/lib/useChannelSections.test.mjs create mode 100644 desktop/src/features/sidebar/lib/useStaleReaderRecovery.ts diff --git a/desktop/src/features/sidebar/lib/channelMutesSync.ts b/desktop/src/features/sidebar/lib/channelMutesSync.ts index 5e8a17e74d..dd40aaac4a 100644 --- a/desktop/src/features/sidebar/lib/channelMutesSync.ts +++ b/desktop/src/features/sidebar/lib/channelMutesSync.ts @@ -46,6 +46,11 @@ export class ChannelMuteSyncManager { private lastRemoteCreatedAt: number; private pendingStore: ChannelMuteStore | null = null; private lastPublishedStore: ChannelMuteStore | null = null; + /** The bootstrap first-copy seed; retired once any relay head is observed. */ + private seedStore: ChannelMuteStore | null = null; + /** Decoded heads, LWW-merged within the 500-entry bound, for each attempt. */ + private remoteStore: ChannelMuteStore | null = null; + private remoteRev = 0; private destroyed = false; constructor(pubkey: string, relayUrl: string) { @@ -71,6 +76,7 @@ export class ChannelMuteSyncManager { if (!result) { return { status: "failed", createdAt: event.created_at }; } + this.remember(result.store); return { status: "found", data: result, @@ -87,6 +93,22 @@ export class ChannelMuteSyncManager { this.lastRemoteCreatedAt = createdAt; } advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt); + // A seed only fills a relay believed empty; any head supersedes it, even + // one whose payload a concurrent reader drops or cannot decrypt. + if (this.seedStore && this.pendingStore === this.seedStore) { + this.cancelPendingMutePublish(); + this.pendingStore = null; + } + this.seedStore = null; + } + + private remember(store: ChannelMuteStore): void { + const next = this.remoteStore + ? mergeStores(this.remoteStore, store) + : store; + // Only a changed winner invalidates in-flight attempts. + if (!this.sameEntries(this.remoteStore, next)) this.remoteRev++; + this.remoteStore = next; } cancelPendingMutePublish(): void { @@ -127,19 +149,21 @@ export class ChannelMuteSyncManager { this.recordRemoteHead(event.created_at); const remote = await decryptAndParse(event); if (!remote) return store; - return mergeStores(store, remote.store); + this.remember(remote.store); + return store; } catch { return store; } } - private isIdenticalToLastPublished(store: ChannelMuteStore): boolean { - if (!this.lastPublishedStore) return false; - const lastKeys = Object.keys(this.lastPublishedStore.channels); + /** Same winners (IDs, `muted`, `updatedAt`), ignoring key order. */ + private sameEntries(prev: ChannelMuteStore | null, store: ChannelMuteStore) { + if (!prev) return false; + const lastKeys = Object.keys(prev.channels); const currentKeys = Object.keys(store.channels); if (lastKeys.length !== currentKeys.length) return false; for (const key of currentKeys) { - const last = this.lastPublishedStore.channels[key]; + const last = prev.channels[key]; const current = store.channels[key]; if ( !last || @@ -152,14 +176,29 @@ export class ChannelMuteSyncManager { } private async doPublish(store: ChannelMuteStore): Promise { + // A seed retired while acquired (preflight, crypto or socket wait) aborts. + const seed = store === this.seedStore; + let rev = this.remoteRev; // a changed winner before dispatch voids it + const stale = () => + this.destroyed || + (seed && this.seedStore !== store) || + rev !== this.remoteRev; + // Only the attempt that still owns `pendingStore` may clear it. + const release = () => { + if (this.pendingStore === store) this.pendingStore = null; + }; try { - const merged = await this.fetchOwnBlobBeforePublish(store); + const pre = await this.fetchOwnBlobBeforePublish(store); + const merged = this.remoteStore + ? mergeStores(pre, this.remoteStore) + : pre; + rev = this.remoteRev; // Guard: manager may have been destroyed while fetchOwnBlobBeforePublish // was awaited (community switch during in-flight fetch). If so, abort // before touching the relay. - if (this.destroyed) return; - if (this.isIdenticalToLastPublished(merged)) { - this.pendingStore = null; + if (stale()) return; + if (this.sameEntries(this.lastPublishedStore, merged)) { + release(); return; } const payload = { @@ -180,17 +219,21 @@ export class ChannelMuteSyncManager { ["t", D_TAG], // relay discoverability; not used in our filters ], }); - if (this.destroyed) return; + if (stale()) return; await relayClient.publishEvent( event, "Timed out publishing channel mutes.", "Failed to publish channel mutes.", + () => !stale(), ); this.recordRemoteHead(event.created_at); this.lastPublishedStore = merged; - this.pendingStore = null; + release(); } catch (error) { console.warn("[channelMutesSync] publish failed:", error); + } finally { + const owned = !this.destroyed && this.pendingStore === store; + if (owned && rev !== this.remoteRev) this.publishMutes(store); } } @@ -210,7 +253,8 @@ export class ChannelMuteSyncManager { // still advances the watermark and blocks future seed-publish. this.recordRemoteHead(event.created_at); void decryptAndParse(event).then((result) => { - if (result) { + if (result && !this.destroyed) { + this.remember(result.store); onUpdate(result); } }); @@ -229,7 +273,10 @@ export class ChannelMuteSyncManager { lastHead: this.lastRemoteCreatedAt, localStore, isLocalNonEmpty: (s) => Object.keys(s.channels).length > 0, - publishFn: (s) => this.publishMutes(s), + publishFn: (s) => { + this.seedStore = s; + this.publishMutes(s); + }, }); } diff --git a/desktop/src/features/sidebar/lib/channelSectionsHelpers.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsHelpers.test.mjs deleted file mode 100644 index a54bc152e2..0000000000 --- a/desktop/src/features/sidebar/lib/channelSectionsHelpers.test.mjs +++ /dev/null @@ -1,79 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { swapSectionOrder } from "./channelSectionsHelpers.ts"; - -function makeStore(sections, assignments = {}) { - return { version: 1, sections, assignments }; -} - -function makeSection(id, name, order) { - return { id, name, order }; -} - -test("move up succeeds: middle section swaps order with the one above", () => { - const store = makeStore([ - makeSection("a", "A", 0), - makeSection("b", "B", 1), - makeSection("c", "C", 2), - ]); - const result = swapSectionOrder(store, "b", "up"); - assert.notEqual(result, null); - const byId = Object.fromEntries(result.sections.map((s) => [s.id, s.order])); - assert.equal(byId.b, 0); - assert.equal(byId.a, 1); - assert.equal(byId.c, 2); -}); - -test("move down succeeds: middle section swaps order with the one below", () => { - const store = makeStore([ - makeSection("a", "A", 0), - makeSection("b", "B", 1), - makeSection("c", "C", 2), - ]); - const result = swapSectionOrder(store, "b", "down"); - assert.notEqual(result, null); - const byId = Object.fromEntries(result.sections.map((s) => [s.id, s.order])); - assert.equal(byId.b, 2); - assert.equal(byId.c, 1); - assert.equal(byId.a, 0); -}); - -test("move up at top boundary returns null", () => { - const store = makeStore([makeSection("a", "A", 0), makeSection("b", "B", 1)]); - assert.equal(swapSectionOrder(store, "a", "up"), null); -}); - -test("move down at bottom boundary returns null", () => { - const store = makeStore([makeSection("a", "A", 0), makeSection("b", "B", 1)]); - assert.equal(swapSectionOrder(store, "b", "down"), null); -}); - -test("non-existent section returns null", () => { - const store = makeStore([makeSection("a", "A", 0)]); - assert.equal(swapSectionOrder(store, "z", "up"), null); -}); - -test("single section move up returns null", () => { - const store = makeStore([makeSection("a", "A", 0)]); - assert.equal(swapSectionOrder(store, "a", "up"), null); -}); - -test("single section move down returns null", () => { - const store = makeStore([makeSection("a", "A", 0)]); - assert.equal(swapSectionOrder(store, "a", "down"), null); -}); - -test("non-contiguous orders: swap uses actual order values not indices", () => { - const store = makeStore([ - makeSection("a", "A", 0), - makeSection("b", "B", 5), - makeSection("c", "C", 10), - ]); - const result = swapSectionOrder(store, "b", "up"); - assert.notEqual(result, null); - const byId = Object.fromEntries(result.sections.map((s) => [s.id, s.order])); - assert.equal(byId.b, 0); - assert.equal(byId.a, 5); - assert.equal(byId.c, 10); -}); diff --git a/desktop/src/features/sidebar/lib/channelSectionsHelpers.ts b/desktop/src/features/sidebar/lib/channelSectionsHelpers.ts deleted file mode 100644 index fe41cfee6b..0000000000 --- a/desktop/src/features/sidebar/lib/channelSectionsHelpers.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { ChannelSectionStore } from "./channelSectionsStorage"; - -export function swapSectionOrder( - prev: ChannelSectionStore, - sectionId: string, - direction: "up" | "down", -): ChannelSectionStore | null { - const target = prev.sections.find((s) => s.id === sectionId); - if (!target) return null; - const sorted = prev.sections.slice().sort((a, b) => a.order - b.order); - const idx = sorted.findIndex((s) => s.id === sectionId); - const neighborIdx = direction === "up" ? idx - 1 : idx + 1; - if (neighborIdx < 0 || neighborIdx >= sorted.length) return null; - const neighbor = sorted[neighborIdx]; - const sections = prev.sections.map((s) => { - if (s.id === target.id) return { ...s, order: neighbor.order }; - if (s.id === neighbor.id) return { ...s, order: target.order }; - return s; - }); - return { ...prev, sections }; -} diff --git a/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs index f28108e5b5..e72fca2e39 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs @@ -2,15 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - boundChannelSectionsStore, - DEFAULT_STORE, - MAX_CHANNEL_SECTION_ASSIGNMENTS, - MAX_CHANNEL_SECTIONS, parseChannelSectionPayload, - readChannelSectionsStore, storageKey, - stripOrphanedAssignments, - writeChannelSectionsStore, } from "./channelSectionsStorage.ts"; import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; @@ -25,19 +18,6 @@ if (typeof globalThis.window === "undefined") { }; } -function makeStore(overrides = {}) { - return { - version: 1, - sections: overrides.sections ?? [{ id: "s1", name: "Test", order: 0 }], - assignments: overrides.assignments ?? {}, - ...overrides, - }; -} - -function makeSection(overrides = {}) { - return { id: "s1", name: "Test", order: 0, ...overrides }; -} - test("parseChannelSectionPayload: valid complete payload returns correct store", () => { const payload = { version: 1, @@ -121,116 +101,6 @@ test("parseChannelSectionPayload: orphaned assignments are stripped", () => { assert.deepEqual(result?.assignments, { chan1: "s1" }); }); -test("stripOrphanedAssignments: store with no orphans returns same reference", () => { - const store = makeStore({ - sections: [makeSection({ id: "s1" })], - assignments: { chan1: "s1" }, - }); - assert.equal(stripOrphanedAssignments(store), store); -}); - -test("stripOrphanedAssignments: store with orphaned assignments returns new object without them", () => { - const store = makeStore({ - sections: [makeSection({ id: "s1" })], - assignments: { chan1: "s1", chan2: "ghost" }, - }); - const result = stripOrphanedAssignments(store); - assert.notEqual(result, store); - assert.deepEqual(result.assignments, { chan1: "s1" }); -}); - -test("stripOrphanedAssignments: store with all valid assignments returns same reference", () => { - const store = makeStore({ - sections: [ - makeSection({ id: "s1" }), - makeSection({ id: "s2", name: "B", order: 1 }), - ], - assignments: { chan1: "s1", chan2: "s2" }, - }); - assert.equal(stripOrphanedAssignments(store), store); -}); - -test("stripOrphanedAssignments: empty store returns same reference", () => { - const store = makeStore({ sections: [], assignments: {} }); - assert.equal(stripOrphanedAssignments(store), store); -}); - -test("boundChannelSectionsStore caps sections and assignments", () => { - const sections = Array.from( - { length: MAX_CHANNEL_SECTIONS + 1 }, - (_, index) => makeSection({ id: `section-${index}`, order: index }), - ); - const assignments = Object.fromEntries( - Array.from({ length: MAX_CHANNEL_SECTION_ASSIGNMENTS + 1 }, (_, index) => [ - `channel-${index}`, - "section-100", - ]), - ); - - const bounded = boundChannelSectionsStore( - makeStore({ sections, assignments }), - ); - - assert.equal(bounded.sections.length, MAX_CHANNEL_SECTIONS); - assert.equal( - bounded.sections.some((section) => section.id === "section-0"), - false, - ); - assert.equal( - Object.keys(bounded.assignments).length, - MAX_CHANNEL_SECTION_ASSIGNMENTS, - ); - assert.equal(bounded.assignments["channel-0"], undefined); -}); - -test("writeChannelSectionsStore + readChannelSectionsStore: write then read returns same data", () => { - const pubkey = "pk-roundtrip"; - const store = makeStore({ - sections: [makeSection({ id: "s1", name: "Work", order: 0 })], - assignments: { chan1: "s1" }, - }); - const written = writeChannelSectionsStore(pubkey, store); - assert.equal(written, true); - const result = readChannelSectionsStore(pubkey); - assert.deepEqual(result, store); -}); - -test("readChannelSectionsStore: non-existent key returns DEFAULT_STORE", () => { - const result = readChannelSectionsStore("pk-does-not-exist-xyz"); - assert.deepEqual(result, DEFAULT_STORE); -}); - -test("readChannelSectionsStore: corrupt JSON returns DEFAULT_STORE", () => { - const pubkey = "pk-corrupt"; - window.localStorage.setItem(storageKey(pubkey), "not-valid-json{{{"); - const result = readChannelSectionsStore(pubkey); - assert.deepEqual(result, DEFAULT_STORE); -}); - -test("readChannelSectionsStore: object with wrong version returns DEFAULT_STORE", () => { - const pubkey = "pk-wrong-version"; - window.localStorage.setItem( - storageKey(pubkey), - JSON.stringify({ version: 2, sections: [], assignments: {} }), - ); - const result = readChannelSectionsStore(pubkey); - assert.deepEqual(result, DEFAULT_STORE); -}); - -test("writeChannelSectionsStore: returns false when setItem throws", () => { - const pubkey = "pk-throws"; - const original = window.localStorage.setItem; - window.localStorage.setItem = () => { - throw new Error("storage full"); - }; - try { - const result = writeChannelSectionsStore(pubkey, makeStore()); - assert.equal(result, false); - } finally { - window.localStorage.setItem = original; - } -}); - test("storageKey: returns expected format with pubkey", () => { assert.equal(storageKey("abc123"), "buzz-channel-sections.v1:abc123"); }); @@ -263,100 +133,6 @@ test("storageKey: equivalent relay URLs (case + trailing slash) map to the same assert.equal(k1, k2); }); -test("readChannelSectionsStore + writeChannelSectionsStore: scoped write/read roundtrip", () => { - const pubkey = "pk-relay-roundtrip"; - const relay = "wss://relay.example.com"; - const store = makeStore({ - sections: [makeSection({ id: "s1", name: "Work", order: 0 })], - assignments: { chan1: "s1" }, - }); - assert.equal(writeChannelSectionsStore(pubkey, store, relay), true); - const result = readChannelSectionsStore(pubkey, relay); - assert.deepEqual(result, store); -}); - -test("readChannelSectionsStore: scoped key is isolated from other relay's data", () => { - const pubkey = "pk-isolation"; - const relayA = "wss://relay-a.example.com"; - const relayB = "wss://relay-b.example.com"; - const storeA = makeStore({ - sections: [makeSection({ id: "sa", name: "Relay A section", order: 0 })], - assignments: {}, - }); - writeChannelSectionsStore(pubkey, storeA, relayA); - // Relay B should see empty store, not relay A's data. - const resultB = readChannelSectionsStore(pubkey, relayB); - assert.deepEqual(resultB, DEFAULT_STORE); -}); - -test("readChannelSectionsStore: migrates legacy unscoped data on first scoped read", () => { - const pubkey = "pk-migrate"; - const relay = "wss://relay-migrate.example.com"; - const legacyStore = makeStore({ - sections: [makeSection({ id: "sl", name: "Legacy Section", order: 0 })], - assignments: {}, - }); - // Write under legacy (pubkey-only) key. - writeChannelSectionsStore(pubkey, legacyStore); - // First scoped read should migrate and return the legacy data. - const result = readChannelSectionsStore(pubkey, relay); - assert.deepEqual(result, legacyStore); - // Legacy key must be deleted after migration (globally one-time guarantee). - const legacyKey = storageKey(pubkey); - assert.equal(window.localStorage.getItem(legacyKey), null); - // Subsequent scoped reads should hit the scoped key directly. - const result2 = readChannelSectionsStore(pubkey, relay); - assert.deepEqual(result2, legacyStore); -}); - -test("readChannelSectionsStore: migration is globally one-time — relay B sees DEFAULT_STORE after relay A migrates", () => { - const pubkey = "pk-migrate-once"; - const relayA = "wss://relay-migrate-once-a.example.com"; - const relayB = "wss://relay-migrate-once-b.example.com"; - const legacyStore = makeStore({ - sections: [makeSection({ id: "sm", name: "Migrated Section", order: 0 })], - assignments: {}, - }); - // Write under legacy key then migrate via relay A. - writeChannelSectionsStore(pubkey, legacyStore); - readChannelSectionsStore(pubkey, relayA); // triggers migration, deletes legacy key - // Relay B must see DEFAULT_STORE — legacy data must not bleed in. - const resultB = readChannelSectionsStore(pubkey, relayB); - assert.deepEqual(resultB, DEFAULT_STORE); - // Relay B scoped key must not have been created. - const scopedBKey = storageKey(pubkey, relayB); - assert.equal(window.localStorage.getItem(scopedBKey), null); -}); - -test("readChannelSectionsStore: migration only copies non-empty legacy stores", () => { - const pubkey = "pk-migrate-empty"; - const relay = "wss://relay-migrate-empty.example.com"; - // Write an explicitly empty store under the legacy key. - writeChannelSectionsStore(pubkey, DEFAULT_STORE); - // Empty legacy store should NOT be migrated — we get DEFAULT_STORE. - const result = readChannelSectionsStore(pubkey, relay); - assert.deepEqual(result, DEFAULT_STORE); -}); - -test("readChannelSectionsStore: scoped key takes precedence over legacy key after migration", () => { - const pubkey = "pk-precedence"; - const relay = "wss://relay-precedence.example.com"; - const legacyStore = makeStore({ - sections: [makeSection({ id: "sold", name: "Old Section", order: 0 })], - assignments: {}, - }); - const newStore = makeStore({ - sections: [makeSection({ id: "snew", name: "New Section", order: 0 })], - assignments: {}, - }); - // Write legacy data then scoped data (simulates post-migration state). - writeChannelSectionsStore(pubkey, legacyStore); - writeChannelSectionsStore(pubkey, newStore, relay); - // Scoped read must return the scoped (newer) store, not the legacy one. - const result = readChannelSectionsStore(pubkey, relay); - assert.deepEqual(result, newStore); -}); - test("parseChannelSectionPayload: preserves icon field when present", () => { const payload = { version: 1, diff --git a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts index f01154751b..ae79a57ead 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts @@ -17,20 +17,14 @@ export type ChannelSectionStore = { assignments: Record; }; -export const DEFAULT_STORE: ChannelSectionStore = Object.freeze({ - version: 1, - sections: [], - assignments: {}, -}); - /** * Returns the localStorage key for channel sections. * * When `relayUrl` is provided the key is scoped to that relay (normalized via * the same `normalizeRelayUrl` used by all relay-scoped local stores) so * sections from different communities/relays don't bleed across each other. - * When omitted the legacy pubkey-only key is returned (used only during - * one-time migration in `readChannelSectionsStore`). + * When omitted the legacy pubkey-only key is returned (migrated once on the + * first scoped read). */ export function storageKey(pubkey: string, relayUrl?: string): string { if (!relayUrl) return `${STORAGE_KEY_PREFIX}:${pubkey}`; @@ -39,42 +33,7 @@ export function storageKey(pubkey: string, relayUrl?: string): string { return `${STORAGE_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalized)}`; } -export function boundChannelSectionsStore( - store: ChannelSectionStore, -): ChannelSectionStore { - const sections = store.sections - .slice() - .sort((left, right) => left.order - right.order) - .slice(-MAX_CHANNEL_SECTIONS); - const sectionIds = new Set(sections.map((section) => section.id)); - const assignments = Object.fromEntries( - Object.entries(store.assignments) - .filter(([, sectionId]) => sectionIds.has(sectionId)) - .slice(-MAX_CHANNEL_SECTION_ASSIGNMENTS), - ); - if ( - sections.length === store.sections.length && - Object.keys(assignments).length === Object.keys(store.assignments).length - ) { - return store; - } - return { ...store, sections, assignments }; -} - -export function stripOrphanedAssignments( - store: ChannelSectionStore, -): ChannelSectionStore { - const sectionIds = new Set(store.sections.map((s) => s.id)); - const cleaned = Object.fromEntries( - Object.entries(store.assignments).filter(([, sid]) => sectionIds.has(sid)), - ); - const stripped = - Object.keys(cleaned).length === Object.keys(store.assignments).length - ? store - : { ...store, assignments: cleaned }; - return boundChannelSectionsStore(stripped); -} - +/** Parses the legacy `sections`/`assignments` fields; orphaned assignments are dropped. */ export function parseChannelSectionPayload( json: unknown, ): ChannelSectionStore | null { @@ -105,92 +64,17 @@ export function parseChannelSectionPayload( ]; }) : []; + const sectionIds = new Set(sections.map((s) => s.id)); const assignments: Record = typeof obj.assignments === "object" && obj.assignments !== null && !Array.isArray(obj.assignments) ? Object.fromEntries( Object.entries(obj.assignments as Record).filter( - (entry): entry is [string, string] => typeof entry[1] === "string", + (entry): entry is [string, string] => + typeof entry[1] === "string" && sectionIds.has(entry[1]), ), ) : {}; - return stripOrphanedAssignments({ version: 1, sections, assignments }); -} - -function parseRaw(raw: string | null): ChannelSectionStore | null { - if (!raw) return null; - try { - const parsed = JSON.parse(raw); - if (typeof parsed !== "object" || parsed === null || parsed.version !== 1) { - return null; - } - return parseChannelSectionPayload(parsed); - } catch { - return null; - } -} - -/** - * Read the section store for `pubkey` scoped to `relayUrl`. - * - * On first access for a scoped key, migrates any existing data from the - * legacy pubkey-only key so users don't lose their sections on upgrade. - * After a successful migration write the legacy key is deleted, making - * this a globally one-time operation — subsequent empty scoped-key reads - * (i.e. a different relay) won't see legacy data and won't trigger - * cross-relay contamination via the seed-publish path. - */ -export function readChannelSectionsStore( - pubkey: string, - relayUrl?: string, -): ChannelSectionStore { - try { - const key = storageKey(pubkey, relayUrl); - const raw = window.localStorage.getItem(key); - - // Scoped key already has data — use it directly. - if (raw !== null) { - return parseRaw(raw) ?? DEFAULT_STORE; - } - - // No scoped data yet. If we were given a relay scope, attempt a - // one-time migration from the legacy pubkey-only key. - if (relayUrl) { - const legacyKey = storageKey(pubkey); - const legacyRaw = window.localStorage.getItem(legacyKey); - const migrated = parseRaw(legacyRaw); - if (migrated && migrated.sections.length > 0) { - // Persist under the scoped key and remove the legacy key so this - // migration cannot fire again for any other relay scope. - try { - window.localStorage.setItem(key, JSON.stringify(migrated)); - window.localStorage.removeItem(legacyKey); - } catch { - // Ignore write failures — we still return the migrated value. - } - return migrated; - } - } - - return DEFAULT_STORE; - } catch { - return DEFAULT_STORE; - } -} - -export function writeChannelSectionsStore( - pubkey: string, - store: ChannelSectionStore, - relayUrl?: string, -): boolean { - try { - window.localStorage.setItem( - storageKey(pubkey, relayUrl), - JSON.stringify(boundChannelSectionsStore(store)), - ); - return true; - } catch { - return false; - } + return { version: 1, sections, assignments }; } diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs deleted file mode 100644 index 904ac1f3f2..0000000000 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs +++ /dev/null @@ -1,282 +0,0 @@ -import assert from "node:assert/strict"; -import test, { mock } from "node:test"; - -import { relayClient } from "@/shared/api/relayClient"; -import { ChannelSectionSyncManager } from "./channelSectionsSync.ts"; -import { - makeFakeWindow, - installFakeWindow, - installTauriMock, -} from "./sidebarSyncTestHelpers.mjs"; - -function makeStore(overrides = {}) { - return { - version: 1, - sections: overrides.sections ?? [], - assignments: overrides.assignments ?? {}, - ...overrides, - }; -} - -function makeSectionsStore(sections = []) { - return { version: 1, sections, assignments: {} }; -} - -const RELAY = "wss://r.test"; -const RELAY_KEY = encodeURIComponent(RELAY); - -// ─── destroy() must cancel pending publish, not flush ───────────────────────── - -// Regression guard for the community-switch cross-relay publish vector: -// edit sections in relay A → destroy() is called (relayUrl dep change) → -// no publish should fire. -test("destroy: cancels pending publish without flushing to the relay", () => { - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - const publishCalls = []; - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSectionSyncManager("pk-test", RELAY); - manager.publishSections( - makeStore({ sections: [{ id: "s1", name: "Work", order: 0 }] }), - ); - assert.ok(fw._hasTimer(), "debounce timer should be set"); - manager.destroy(); - assert.ok(!fw._hasTimer(), "debounce timer should be cleared on destroy"); - assert.equal(publishCalls.length, 0); - assert.equal(manager.getPendingStore(), null); - } finally { - restore(); - mock.reset(); - } -}); - -// Regression guard for the timer-fired race: debounce fires → doPublish awaits -// fetchOwnBlobBeforePublish → destroy() called → publishEvent must not fire. -test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => { - let releaseFetch = null; - const publishCalls = []; - mock.method( - relayClient, - "fetchEvents", - () => - new Promise((res) => { - releaseFetch = () => res([]); - }), - ); - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSectionSyncManager("pk-race", RELAY); - manager.publishSections( - makeStore({ sections: [{ id: "s1", name: "Work", order: 0 }] }), - ); - fw._fireTimer(); // starts doPublish, which is now awaiting fetchOwnBlobBeforePublish - manager.destroy(); - releaseFetch(); - await new Promise((r) => setTimeout(r, 0)); - assert.equal( - publishCalls.length, - 0, - "publishEvent must not fire after destroy", - ); - } finally { - restore(); - mock.reset(); - } -}); - -test("destroy: is safe to call with no pending publish", () => { - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSectionSyncManager("pk-no-pending", RELAY); - assert.doesNotThrow(() => manager.destroy()); - } finally { - restore(); - } -}); - -// ─── Boot seed-publish guard (the revert-fix regression suite) ──────────────── -// Wiring tests 1-3 drive the production bootstrap() path; policy tested once -// in sidebarSyncWatermark.test.mjs. - -// 1. fetch failed → hold, pendingStore null (mutation: remove failed guard → seed queued) -test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { - mock.method(relayClient, "fetchEvents", () => - Promise.reject(new Error("relay timeout")), - ); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSectionSyncManager("pk-fail", RELAY); - const result = await manager.bootstrap( - makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]), - ); - assert.equal(result.action, "hold"); - assert.equal(manager.getPendingStore(), null); - } finally { - restore(); - mock.reset(); - } -}); - -// 2. absent + prior watermark → hold, pendingStore null (mutation: clear watermark → seed queued) -test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - fw.localStorage.setItem( - `buzz-sync-watermark.v1:channel-sections:pk-stale:${RELAY_KEY}`, - "1700000000", - ); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSectionSyncManager("pk-stale", RELAY); - assert.ok( - Number( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-sections:pk-stale:${RELAY_KEY}`, - ) ?? "0", - ) > 0, - ); - const result = await manager.bootstrap( - makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]), - ); - assert.equal(result.action, "hold"); - assert.equal(manager.getPendingStore(), null); - } finally { - restore(); - mock.reset(); - } -}); - -// 3. absent + zero watermark + non-empty → seed queued (mutation: remove seed call → pendingStore null) -test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => { - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSectionSyncManager("pk-fresh", RELAY); - const result = await manager.bootstrap( - makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]), - ); - assert.equal(result.action, "hold"); - assert.ok(manager.getPendingStore() !== null); - } finally { - restore(); - mock.reset(); - } -}); - -// 4. LWW baseline: newer decryptable pre-publish event still wins after an -// undecryptable head was recorded. -// Mutation test: headBeforeFetch → this.lastRemoteCreatedAt makes comparison -// 200>200=false → local wins instead of remote → wrong content encrypted. -test("revert-fix: sections LWW — newer decryptable pre-publish event selected after undecryptable head recorded", async () => { - const REMOTE_ID = "remote-section-from-relay"; - let callCount = 0; - mock.method(relayClient, "fetchEvents", () => { - callCount++; - return Promise.resolve([ - { - pubkey: "pk-lww", - content: callCount === 1 ? "bad-cipher" : "good-cipher", - created_at: callCount === 1 ? 100 : 200, - id: `evt-${callCount}`, - }, - ]); - }); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - const tauri = installTauriMock( - JSON.stringify({ - version: 1, - sections: [{ id: REMOTE_ID, name: "Remote", order: 0 }], - assignments: {}, - }), - ); - try { - const manager = new ChannelSectionSyncManager("pk-lww", RELAY); - await manager.fetchRemoteSections(); - assert.ok( - Number( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-sections:pk-lww:${RELAY_KEY}`, - ) ?? "0", - ) >= 100, - ); - manager.publishSections( - makeSectionsStore([{ id: "local-s", name: "Local", order: 0 }]), - ); - fw._fireTimer(); - await new Promise((r) => setTimeout(r, 20)); - const pt = tauri.capturedPlaintext(); - assert.ok(pt !== null, "nip44EncryptToSelf must have been called"); - assert.ok( - JSON.parse(pt).sections?.some((s) => s.id === REMOTE_ID), - `remote sections must win LWW merge — got: ${pt}`, - ); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } -}); - -// 5. live-sub: undecryptable event on live path records head before decrypt -// Mutation test: removing recordRemoteHead before decrypt in the live callback -// leaves watermark at 0 after a live event. -test("revert-fix: undecryptable live event advances watermark before decrypt attempt", async () => { - let liveCallback = null; - mock.method(relayClient, "subscribeLive", (_filter, onEvent) => { - liveCallback = onEvent; - return Promise.resolve(async () => {}); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSectionSyncManager("pk-live", RELAY); - assert.equal( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-sections:pk-live:${RELAY_KEY}`, - ), - null, - "watermark starts absent", - ); - await manager.subscribeToSections(() => {}); - assert.ok( - liveCallback !== null, - "subscribeLive must have captured the callback", - ); - liveCallback({ - pubkey: "pk-live", - content: "!bad-cipher!", - created_at: 1700005555, - id: "live-evt-1", - }); - await new Promise((r) => setTimeout(r, 0)); - assert.ok( - Number( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-sections:pk-live:${RELAY_KEY}`, - ) ?? "0", - ) >= 1700005555, - "live undecryptable event must advance the watermark before decrypt is attempted", - ); - } finally { - restore(); - mock.reset(); - } -}); diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.ts b/desktop/src/features/sidebar/lib/channelSectionsSync.ts index 858b62430f..1774bbbf93 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.ts @@ -1,276 +1,107 @@ -import { relayClient } from "@/shared/api/relayClient"; -import { - nip44DecryptFromSelf, - nip44EncryptToSelf, - signRelayEvent, -} from "@/shared/api/tauri"; -import type { RelayEvent } from "@/shared/api/types"; -import { KIND_CHANNEL_SECTIONS } from "@/shared/constants/kinds"; import { + MAX_CHANNEL_SECTION_ASSIGNMENTS, + MAX_CHANNEL_SECTIONS, parseChannelSectionPayload, + storageKey, type ChannelSection, - type ChannelSectionStore, } from "./channelSectionsStorage"; -import { - advanceWatermark, - readWatermark, - runBootstrap, - type FetchResult, -} from "./sidebarSyncWatermark"; +import type { Lane } from "./sidebarLaneReconciler"; +import { isReg, type Reg, type Tree } from "./sidebarLwwMap"; -const D_TAG = "channel-sections"; -const BLOB_TYPE = D_TAG; -const DEBOUNCE_MS = 2_000; +const isString = (value: unknown) => typeof value === "string"; -export type RemoteSections = { - store: ChannelSectionStore; - createdAt: number; - eventId: string; +type SectionNode = { name?: Reg; icon?: Reg; order?: Reg; live?: Reg }; + +export type SectionsView = { + sections: ChannelSection[]; + assignments: Record; }; -async function decryptAndParse( - event: RelayEvent, -): Promise { - try { - const plaintext = await nip44DecryptFromSelf(event.content); - const store = parseChannelSectionPayload(JSON.parse(plaintext)); - if (!store) return null; - return { store, createdAt: event.created_at, eventId: event.id }; - } catch { - return null; - } -} +const val = (reg: unknown) => (isReg(reg) ? reg[2] : undefined); -export class ChannelSectionSyncManager { - private pubkey: string; - private relayUrl: string; - private debounceTimer: number | null = null; - private lastRemoteCreatedAt: number; - private pendingStore: ChannelSectionStore | null = null; - private lastPublishedStore: ChannelSectionStore | null = null; - private destroyed = false; - - constructor(pubkey: string, relayUrl: string) { - this.pubkey = pubkey; - this.relayUrl = relayUrl; - // Hydrate from localStorage so we never seed-publish if a remote blob has - // been seen in a prior session. - this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); - } - - async fetchRemoteSections(): Promise> { - try { - const events = await relayClient.fetchEvents({ - kinds: [KIND_CHANNEL_SECTIONS], - authors: [this.pubkey], - "#d": [D_TAG], - limit: 1, - }); - if (events.length === 0 || events[0].pubkey !== this.pubkey) { - return { status: "absent" }; - } - const event = events[0]; - // An event exists — record its created_at regardless of whether we can - // decrypt it, so seed-publish is blocked even when the payload is - // unreadable (e.g. wrong key). - this.recordRemoteHead(event.created_at); - const result = await decryptAndParse(event); - if (!result) { - return { status: "failed", createdAt: event.created_at }; - } - return { - status: "found", - data: result, - createdAt: result.createdAt, - eventId: result.eventId, - }; - } catch { - return { status: "failed" }; - } - } - - /** Update in-memory + persisted watermark. */ - private recordRemoteHead(createdAt: number): void { - if (createdAt > this.lastRemoteCreatedAt) { - this.lastRemoteCreatedAt = createdAt; - } - advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt); - } - - cancelPendingPublish(): void { - if (this.debounceTimer !== null) { - window.clearTimeout(this.debounceTimer); - this.debounceTimer = null; - } - } - - getPendingStore(): ChannelSectionStore | null { - return this.pendingStore; - } - - publishSections(store: ChannelSectionStore): void { - this.pendingStore = store; - if (this.debounceTimer !== null) { - window.clearTimeout(this.debounceTimer); - } - this.debounceTimer = window.setTimeout(() => { - this.debounceTimer = null; - void this.doPublish(store); - }, DEBOUNCE_MS); - } - - private async fetchOwnBlobBeforePublish( - store: ChannelSectionStore, - ): Promise { - try { - const events = await relayClient.fetchEvents({ - kinds: [KIND_CHANNEL_SECTIONS], - authors: [this.pubkey], - "#d": [D_TAG], - limit: 1, - }); - if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; - const event = events[0]; - // Snapshot the watermark before advancing it: after recordRemoteHead - // runs, lastRemoteCreatedAt equals event.created_at, so the LWW - // comparison remote.createdAt > lastRemoteCreatedAt would always be - // false and silently suppress the merge. - const headBeforeFetch = this.lastRemoteCreatedAt; - this.recordRemoteHead(event.created_at); - const remote = await decryptAndParse(event); - if (!remote) return store; - // Sections use whole-blob LWW: take whichever is newer - if (remote.createdAt > headBeforeFetch) { - return remote.store; - } - return store; - } catch { - return store; - } - } - - private isIdenticalToLastPublished(store: ChannelSectionStore): boolean { - if (!this.lastPublishedStore) return false; - const lastSections = this.lastPublishedStore.sections; - const currentSections = store.sections; - if (lastSections.length !== currentSections.length) return false; - for (let i = 0; i < currentSections.length; i++) { - const last = lastSections[i] as ChannelSection | undefined; - const current = currentSections[i] as ChannelSection; - if ( - !last || - last.id !== current.id || - last.name !== current.name || - last.icon !== current.icon || - last.order !== current.order - ) - return false; - } - const lastAssignKeys = Object.keys(this.lastPublishedStore.assignments); - const currentAssignKeys = Object.keys(store.assignments); - if (lastAssignKeys.length !== currentAssignKeys.length) return false; - for (const key of currentAssignKeys) { - if (this.lastPublishedStore.assignments[key] !== store.assignments[key]) - return false; - } - return true; - } - - private async doPublish(store: ChannelSectionStore): Promise { - try { - const merged = await this.fetchOwnBlobBeforePublish(store); - // Guard: manager may have been destroyed while fetchOwnBlobBeforePublish - // was awaited (community switch during in-flight fetch). If so, abort - // before touching the relay. - if (this.destroyed) return; - if (this.isIdenticalToLastPublished(merged)) { - this.pendingStore = null; - return; - } - const payload = { - version: 1, - sections: merged.sections, - assignments: merged.assignments, - }; - const ciphertext = await nip44EncryptToSelf(JSON.stringify(payload)); - const createdAt = Math.max( - Math.floor(Date.now() / 1_000), - this.lastRemoteCreatedAt + 1, - ); - const event = await signRelayEvent({ - kind: KIND_CHANNEL_SECTIONS, - content: ciphertext, - createdAt, - tags: [ - ["d", D_TAG], - ["t", D_TAG], // relay discoverability; not used in our filters - ], - }); - // Final guard immediately before the network call — sign/encrypt are - // synchronous-ish but cheap; the relay socket may have moved to a - // different community by the time we reach this point. - if (this.destroyed) return; - await relayClient.publishEvent( - event, - "Timed out publishing channel sections.", - "Failed to publish channel sections.", - ); - this.recordRemoteHead(event.created_at); - this.lastPublishedStore = merged; - this.pendingStore = null; - } catch (error) { - console.warn("[channelSectionsSync] publish failed:", error); - } - } - - async subscribeToSections( - onUpdate: (remote: RemoteSections) => void, - ): Promise<() => Promise> { - return relayClient.subscribeLive( - { - kinds: [KIND_CHANNEL_SECTIONS], - authors: [this.pubkey], - "#d": [D_TAG], - limit: 0, - }, - (event: RelayEvent) => { - if (event.pubkey !== this.pubkey) return; - // Record the raw head before decrypt so an undecryptable live event - // still advances the watermark and blocks future seed-publish. - this.recordRemoteHead(event.created_at); - void decryptAndParse(event).then((result) => { - if (result) { - onUpdate(result); - } - }); - }, +/** Live sections by `(order, id)`, with their canonical `order`. */ +function rankedSections(tree: Tree) { + const nodes = (tree.s ?? {}) as Record; + return Object.entries(nodes) + .filter(([, n]) => val(n.live) === true && isString(val(n.name))) + .map(([id, n]) => ({ + id, + name: val(n.name) as string, + icon: val(n.icon), + order: Number(val(n.order) ?? 0), + })) + .sort( + (a, b) => a.order - b.order || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), ); - } - - /** - * Fetches the remote blob on first mount, records the remote head, and - * delegates the seed/hold/apply-remote decision to `runBootstrap`. - */ - async bootstrap(localStore: ChannelSectionStore) { - const fetchResult = await this.fetchRemoteSections(); - return runBootstrap({ - fetchResult, - lastHead: this.lastRemoteCreatedAt, - localStore, - isLocalNonEmpty: (s) => s.sections.length > 0, - publishFn: (s) => this.publishSections(s), - }); - } - - destroy(): void { - // Cancel any pending publish and mark this manager as destroyed so any - // in-flight doPublish() calls abort before reaching relayClient. - // Pending debounce-window changes are intentionally dropped: flushing - // could publish relay A's sections to relay B via the shared relayClient - // singleton. On return, bootstrap's found path whole-blob-replaces from - // remote, so any dropped pending edit is lost. - this.destroyed = true; - this.cancelPendingPublish(); - this.pendingStore = null; - } } + +/** Highest canonical `order` among live sections, or -1 when there are none. */ +export const maxLiveOrder = (tree: Tree) => + Math.max(-1, ...rankedSections(tree).map((s) => s.order)); + +/** Live sections, densely renumbered. */ +function liveSections(tree: Tree): ChannelSection[] { + return rankedSections(tree).map(({ icon, ...s }, order) => ({ + ...s, + ...(isString(icon) && icon ? { icon: icon as string } : {}), + order, + })); +} + +/** The sections and assignments older clients (and this UI) read. */ +export function projectSections(tree: Tree): SectionsView { + const sections = liveSections(tree); + const live = new Set(sections.map((s) => s.id)); + const assignments: Record = {}; + for (const [channelId, reg] of Object.entries(tree.a ?? {})) { + const target = val(reg); + if (isString(target) && live.has(target as string)) + assignments[channelId] = target as string; + } + return { sections, assignments }; +} + +/** Channel sections: kind 30078, d-tag `channel-sections`, per-field registers. */ +export const SECTIONS_LANE: Lane = { + dTag: "channel-sections", + storageKey, + legacyStorageKey: (pubkey) => storageKey(pubkey), + shape: { + s: { + "*": { + name: isString, + icon: (v) => v === null || isString(v), + order: Number.isSafeInteger, + live: (v) => typeof v === "boolean", + }, + }, + a: { "*": (v) => v === null || isString(v) }, + }, + fromLegacy(json, stamp) { + const store = parseChannelSectionPayload(json); + if (!store) return null; + const s: Tree = Object.fromEntries( + store.sections.map(({ id, name, icon, order }) => [ + id, + { + name: stamp(name), + icon: stamp(icon ?? null), + order: stamp(Math.round(order)), + live: stamp(true), + }, + ]), + ); + const a: Tree = Object.fromEntries( + Object.entries(store.assignments).map(([id, target]) => [ + id, + stamp(target), + ]), + ); + return { s, a }; + }, + project: projectSections, + withinLimits: (p) => + (p.sections as unknown[]).length <= MAX_CHANNEL_SECTIONS && + Object.keys(p.assignments as object).length <= + MAX_CHANNEL_SECTION_ASSIGNMENTS, +}; diff --git a/desktop/src/features/sidebar/lib/channelSortPreference.test.mjs b/desktop/src/features/sidebar/lib/channelSortPreference.test.mjs index a7422d3a21..360e7410dc 100644 --- a/desktop/src/features/sidebar/lib/channelSortPreference.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSortPreference.test.mjs @@ -2,15 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - boundChannelSortStore, - DEFAULT_SORT_MODE, - DEFAULT_STORE, - MAX_CHANNEL_SORT_GROUPS, parseChannelSortPayload, - sectionSortGroupKey, sortChannelsForSidebar, - sortModeForGroup, - stripOrphanedSectionModes, } from "./channelSortPreference.ts"; function makeChannel(id, name, lastMessageAt = null) { @@ -81,116 +74,6 @@ test("parseChannelSortPayload: wrong version returns null", () => { ); }); -test("parseChannelSortPayload: non-object input returns null", () => { - assert.equal(parseChannelSortPayload(null), null); - assert.equal(parseChannelSortPayload("alpha"), null); - assert.equal(parseChannelSortPayload(42), null); -}); - -// ── sortModeForGroup / defaults ────────────────────────────────────────────── - -test("default sort mode is alpha and default store has no overrides", () => { - assert.equal(DEFAULT_SORT_MODE, "alpha"); - assert.deepEqual(DEFAULT_STORE.groups, {}); -}); - -test("sortModeForGroup: unset group falls back to alpha", () => { - assert.equal(sortModeForGroup(DEFAULT_STORE, "channels"), "alpha"); - assert.equal(sortModeForGroup(DEFAULT_STORE, "dms"), "alpha"); -}); - -test("sortModeForGroup: set groups are independent", () => { - const store = { - version: 1, - groups: { channels: "recent", [sectionSortGroupKey("abc")]: "recent" }, - }; - assert.equal(sortModeForGroup(store, "channels"), "recent"); - assert.equal(sortModeForGroup(store, sectionSortGroupKey("abc")), "recent"); - assert.equal(sortModeForGroup(store, "forums"), "alpha"); - assert.equal(sortModeForGroup(store, sectionSortGroupKey("xyz")), "alpha"); -}); - -test("sectionSortGroupKey: namespaced by section id", () => { - assert.equal(sectionSortGroupKey("abc"), "section:abc"); -}); - -// ── stripOrphanedSectionModes ──────────────────────────────────────────────── - -test("stripOrphanedSectionModes: drops modes for deleted sections", () => { - const store = { - version: 1, - groups: { - channels: "recent", - [sectionSortGroupKey("live")]: "recent", - [sectionSortGroupKey("deleted")]: "alpha", - }, - }; - assert.deepEqual(stripOrphanedSectionModes(store, ["live"]), { - version: 1, - groups: { channels: "recent", [sectionSortGroupKey("live")]: "recent" }, - }); -}); - -test("stripOrphanedSectionModes: fixed groups survive with no live sections", () => { - const store = { - version: 1, - groups: { - starred: "recent", - channels: "alpha", - forums: "recent", - dms: "recent", - [sectionSortGroupKey("gone")]: "recent", - }, - }; - assert.deepEqual(stripOrphanedSectionModes(store, []), { - version: 1, - groups: { - starred: "recent", - channels: "alpha", - forums: "recent", - dms: "recent", - }, - }); -}); - -test("stripOrphanedSectionModes: returns same reference when nothing is stale", () => { - const store = { - version: 1, - groups: { channels: "recent", [sectionSortGroupKey("live")]: "recent" }, - }; - assert.equal(stripOrphanedSectionModes(store, ["live", "other"]), store); -}); - -test("stripOrphanedSectionModes: does not mutate the input store", () => { - const store = { - version: 1, - groups: { [sectionSortGroupKey("gone")]: "recent" }, - }; - stripOrphanedSectionModes(store, []); - assert.deepEqual(store.groups, { [sectionSortGroupKey("gone")]: "recent" }); -}); - -test("boundChannelSortStore caps custom sections while preserving fixed groups", () => { - const groups = { - channels: "recent", - ...Object.fromEntries( - Array.from({ length: MAX_CHANNEL_SORT_GROUPS }, (_, index) => [ - sectionSortGroupKey(String(index)), - "alpha", - ]), - ), - }; - - const bounded = boundChannelSortStore({ version: 1, groups }); - - assert.equal(Object.keys(bounded.groups).length, MAX_CHANNEL_SORT_GROUPS); - assert.equal(bounded.groups.channels, "recent"); - assert.equal(bounded.groups[sectionSortGroupKey("0")], undefined); - assert.equal(bounded.groups[sectionSortGroupKey("99")], "alpha"); -}); - -// ── sortChannelsForSidebar ─────────────────────────────────────────────────── - test("alpha: sorts case-insensitively with deterministic code-unit collation", () => { const sorted = sortChannelsForSidebar( [ diff --git a/desktop/src/features/sidebar/lib/channelSortPreference.ts b/desktop/src/features/sidebar/lib/channelSortPreference.ts index 4de9768d84..446b0d5947 100644 --- a/desktop/src/features/sidebar/lib/channelSortPreference.ts +++ b/desktop/src/features/sidebar/lib/channelSortPreference.ts @@ -24,11 +24,6 @@ export type ChannelSortStore = { export const DEFAULT_SORT_MODE: ChannelSortMode = "alpha"; -export const DEFAULT_STORE: ChannelSortStore = Object.freeze({ - version: 1, - groups: {}, -}); - export function sectionSortGroupKey(sectionId: string): ChannelSortGroupKey { return `section:${sectionId}`; } @@ -47,43 +42,7 @@ export function storageKey(pubkey: string, relayUrl?: string): string { return `${STORAGE_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalized)}`; } -/** - * Drops per-section sort modes whose custom section no longer exists so - * deleted sections don't leave stale `section:` keys in localStorage - * forever. Fixed group keys (starred/channels/forums/dms) are always kept. - * Returns the same store reference when nothing needs stripping. - */ -export function stripOrphanedSectionModes( - store: ChannelSortStore, - liveSectionIds: Iterable, -): ChannelSortStore { - const liveKeys = new Set( - [...liveSectionIds].map((id) => sectionSortGroupKey(id)), - ); - const kept = Object.entries(store.groups).filter( - ([key]) => !key.startsWith("section:") || liveKeys.has(key), - ); - if (kept.length === Object.keys(store.groups).length) return store; - return { ...store, groups: Object.fromEntries(kept) }; -} - -export function boundChannelSortStore( - store: ChannelSortStore, -): ChannelSortStore { - const entries = Object.entries(store.groups); - if (entries.length <= MAX_CHANNEL_SORT_GROUPS) return store; - const isFixedGroup = (key: string) => - key === "starred" || - key === "channels" || - key === "forums" || - key === "dms"; - const fixed = entries.filter(([key]) => isFixedGroup(key)); - const custom = entries - .filter(([key]) => !isFixedGroup(key)) - .slice(-(MAX_CHANNEL_SORT_GROUPS - fixed.length)); - return { ...store, groups: Object.fromEntries([...fixed, ...custom]) }; -} - +/** Parses the legacy `groups` field; unknown keys are kept, unknown modes dropped. */ export function parseChannelSortPayload( json: unknown, ): ChannelSortStore | null { @@ -101,43 +60,7 @@ export function parseChannelSortPayload( ), ) : {}; - return boundChannelSortStore({ version: 1, groups }); -} - -export function readChannelSortStore( - pubkey: string, - relayUrl?: string, -): ChannelSortStore { - try { - const raw = window.localStorage.getItem(storageKey(pubkey, relayUrl)); - if (!raw) return DEFAULT_STORE; - return parseChannelSortPayload(JSON.parse(raw)) ?? DEFAULT_STORE; - } catch { - return DEFAULT_STORE; - } -} - -export function writeChannelSortStore( - pubkey: string, - store: ChannelSortStore, - relayUrl?: string, -): boolean { - try { - window.localStorage.setItem( - storageKey(pubkey, relayUrl), - JSON.stringify(boundChannelSortStore(store)), - ); - return true; - } catch { - return false; - } -} - -export function sortModeForGroup( - store: ChannelSortStore, - group: ChannelSortGroupKey, -): ChannelSortMode { - return store.groups[group] ?? DEFAULT_SORT_MODE; + return { version: 1, groups }; } function channelRecencyMs(channel: Channel): number | null { diff --git a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs deleted file mode 100644 index 28159eedd3..0000000000 --- a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs +++ /dev/null @@ -1,262 +0,0 @@ -import assert from "node:assert/strict"; -import test, { mock } from "node:test"; - -import { relayClient } from "@/shared/api/relayClient"; -import { ChannelSortSyncManager } from "./channelSortSync.ts"; -import { - makeFakeWindow, - installFakeWindow, - installTauriMock, -} from "./sidebarSyncTestHelpers.mjs"; - -function makeStore(groups = {}) { - return { version: 1, groups }; -} - -const RELAY = "wss://r.test"; -const RELAY_KEY = encodeURIComponent(RELAY); - -// ─── destroy() must cancel pending publish, not flush ───────────────────────── -test("destroy: cancels pending publish without flushing to the relay", () => { - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - const publishCalls = []; - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSortSyncManager("pk-test", RELAY); - manager.publishSortPrefs(makeStore({ channels: "recent" })); - assert.ok(fw._hasTimer(), "debounce timer should be set"); - manager.destroy(); - assert.ok(!fw._hasTimer(), "debounce timer should be cleared on destroy"); - assert.equal(publishCalls.length, 0); - assert.equal(manager.getPendingStore(), null); - } finally { - restore(); - mock.reset(); - } -}); - -// Regression guard for the timer-fired race: debounce fires → doPublish awaits -// fetchOwnBlobBeforePublish → destroy() called → publishEvent must not fire. -test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => { - let releaseFetch = null; - const publishCalls = []; - mock.method( - relayClient, - "fetchEvents", - () => - new Promise((res) => { - releaseFetch = () => res([]); - }), - ); - mock.method(relayClient, "publishEvent", (...args) => { - publishCalls.push(args); - return Promise.resolve(); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSortSyncManager("pk-race", RELAY); - manager.publishSortPrefs(makeStore({ dms: "recent" })); - fw._fireTimer(); // starts doPublish, which is now awaiting fetchOwnBlobBeforePublish - manager.destroy(); - releaseFetch(); - await new Promise((r) => setTimeout(r, 0)); - assert.equal( - publishCalls.length, - 0, - "publishEvent must not fire after destroy", - ); - } finally { - restore(); - mock.reset(); - } -}); - -test("destroy: is safe to call with no pending publish", () => { - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSortSyncManager("pk-no-pending", RELAY); - assert.doesNotThrow(() => manager.destroy()); - } finally { - restore(); - } -}); - -// ─── Boot seed-publish guard (the revert-fix regression suite) ──────────────── -// Wiring tests 1-3 drive the production bootstrap() path; policy tested once -// in sidebarSyncWatermark.test.mjs. - -// 1. fetch failed (error/timeout) + local non-empty → hold, zero publish calls -// Mutation: removing the failed guard causes bootstrap to call publishSortPrefs → pendingStore set. -test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { - mock.method(relayClient, "fetchEvents", () => - Promise.reject(new Error("relay timeout")), - ); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSortSyncManager("pk-fail", RELAY); - const result = await manager.bootstrap(makeStore({ channels: "recent" })); - assert.equal(result.action, "hold"); - assert.equal(manager.getPendingStore(), null); - } finally { - restore(); - mock.reset(); - } -}); - -// 2. absent + persisted head > 0 → hold, zero publish calls (the dev-build stale-copy case) -// Mutation: setting watermark to 0 in localStorage causes bootstrap to seed. -test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - fw.localStorage.setItem( - `buzz-sync-watermark.v1:channel-sort:pk-stale:${RELAY_KEY}`, - "1700000000", - ); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSortSyncManager("pk-stale", RELAY); - assert.ok( - Number( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-sort:pk-stale:${RELAY_KEY}`, - ) ?? "0", - ) > 0, - ); - const result = await manager.bootstrap(makeStore({ channels: "recent" })); - assert.equal(result.action, "hold"); - assert.equal(manager.getPendingStore(), null); - } finally { - restore(); - mock.reset(); - } -}); - -// 3. absent + head 0 + local non-empty → seed-publish queued (first-sync preserved) -// Mutation: removing the absent+head-0 seed call leaves pendingStore null. -test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => { - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSortSyncManager("pk-fresh", RELAY); - assert.equal( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-sort:pk-fresh:${RELAY_KEY}`, - ), - null, - ); - const result = await manager.bootstrap(makeStore({ channels: "recent" })); - assert.equal(result.action, "hold"); - assert.ok(manager.getPendingStore() !== null); - } finally { - restore(); - mock.reset(); - } -}); - -// 4. LWW baseline: newer decryptable pre-publish event still wins after an -// undecryptable head was recorded. -// Mutation test: headBeforeFetch → this.lastRemoteCreatedAt makes comparison -// 200>200=false → local wins instead of remote → wrong content encrypted. -test("revert-fix: sort LWW — newer decryptable pre-publish event selected after undecryptable head recorded", async () => { - const REMOTE_KEY = "remote-group-from-relay"; - let callCount = 0; - mock.method(relayClient, "fetchEvents", () => { - callCount++; - return Promise.resolve([ - { - pubkey: "pk-lww", - content: callCount === 1 ? "bad-cipher" : "good-cipher", - created_at: callCount === 1 ? 100 : 200, - id: `evt-${callCount}`, - }, - ]); - }); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - const tauri = installTauriMock( - JSON.stringify({ version: 1, groups: { [REMOTE_KEY]: "recent" } }), - ); - try { - const manager = new ChannelSortSyncManager("pk-lww", RELAY); - await manager.fetchRemoteSortPrefs(); - assert.ok( - Number( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-sort:pk-lww:${RELAY_KEY}`, - ) ?? "0", - ) >= 100, - ); - manager.publishSortPrefs(makeStore({ "local-group": "recent" })); - fw._fireTimer(); - await new Promise((r) => setTimeout(r, 20)); - const pt = tauri.capturedPlaintext(); - assert.ok(pt !== null, "nip44EncryptToSelf must have been called"); - assert.ok( - JSON.parse(pt).groups && REMOTE_KEY in JSON.parse(pt).groups, - `remote groups must win LWW merge — got: ${pt}`, - ); - } finally { - tauri.restore(); - restore(); - mock.reset(); - } -}); - -// 5. live-sub: undecryptable event on live path records head before decrypt -// Mutation test: removing recordRemoteHead before decrypt in the live callback -// leaves watermark at 0 after a live event. -test("revert-fix: undecryptable live event advances watermark before decrypt attempt", async () => { - let liveCallback = null; - mock.method(relayClient, "subscribeLive", (_filter, onEvent) => { - liveCallback = onEvent; - return Promise.resolve(async () => {}); - }); - const fw = makeFakeWindow(); - const restore = installFakeWindow(fw); - try { - const manager = new ChannelSortSyncManager("pk-live", RELAY); - assert.equal( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-sort:pk-live:${RELAY_KEY}`, - ), - null, - "watermark starts absent", - ); - await manager.subscribeToSortPrefs(() => {}); - assert.ok( - liveCallback !== null, - "subscribeLive must have captured the callback", - ); - liveCallback({ - pubkey: "pk-live", - content: "!bad-cipher!", - created_at: 1700005555, - id: "live-evt-1", - }); - await new Promise((r) => setTimeout(r, 0)); - assert.ok( - Number( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-sort:pk-live:${RELAY_KEY}`, - ) ?? "0", - ) >= 1700005555, - "live undecryptable event must advance the watermark before decrypt is attempted", - ); - } finally { - restore(); - mock.reset(); - } -}); diff --git a/desktop/src/features/sidebar/lib/channelSortSync.ts b/desktop/src/features/sidebar/lib/channelSortSync.ts index fe71fe62df..6129c7a313 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.ts +++ b/desktop/src/features/sidebar/lib/channelSortSync.ts @@ -1,263 +1,37 @@ -import { relayClient } from "@/shared/api/relayClient"; -import { - nip44DecryptFromSelf, - nip44EncryptToSelf, - signRelayEvent, -} from "@/shared/api/tauri"; -import type { RelayEvent } from "@/shared/api/types"; -import { KIND_CHANNEL_SORT } from "@/shared/constants/kinds"; import { + MAX_CHANNEL_SORT_GROUPS, parseChannelSortPayload, - type ChannelSortStore, + storageKey, + type ChannelSortMode, } from "./channelSortPreference"; -import { - advanceWatermark, - readWatermark, - runBootstrap, - type FetchResult, -} from "./sidebarSyncWatermark"; +import type { Lane } from "./sidebarLaneReconciler"; +import { isReg, type Tree } from "./sidebarLwwMap"; -const D_TAG = "channel-sort"; -const BLOB_TYPE = D_TAG; -const DEBOUNCE_MS = 2_000; +/** Non-null sort modes per group key; `null` registers are explicit resets. */ +export function projectSort(tree: Tree): { + groups: Record; +} { + const groups: Record = {}; + for (const [key, reg] of Object.entries(tree.g ?? {})) { + if (isReg(reg) && reg[2] !== null) groups[key] = reg[2] as ChannelSortMode; + } + return { groups }; +} -export type RemoteSortPrefs = { - store: ChannelSortStore; - createdAt: number; - eventId: string; -}; - -async function decryptAndParse( - event: RelayEvent, -): Promise { - try { - const plaintext = await nip44DecryptFromSelf(event.content); - const store = parseChannelSortPayload(JSON.parse(plaintext)); +/** Sidebar sort modes: kind 30078, d-tag `channel-sort`, one register per group key. */ +export const SORT_LANE: Lane = { + dTag: "channel-sort", + storageKey, + shape: { g: { "*": (v) => v === null || v === "alpha" || v === "recent" } }, + fromLegacy(json, stamp) { + const store = parseChannelSortPayload(json); if (!store) return null; - return { store, createdAt: event.created_at, eventId: event.id }; - } catch { - return null; - } -} - -/** - * Syncs the per-group sidebar sort preferences across clients via encrypted - * NIP-78 app data (kind 30078, d-tag `channel-sort`), following the same - * pattern as channel sections: NIP-44 encrypted-to-self content, debounced - * writes, and whole-blob last-write-wins. The sort map is a compact, - * low-frequency preference blob, so whole-blob LWW (like sections) is - * sufficient — per-key merge (like stars/mutes) would be unnecessary - * complexity here. - */ -export class ChannelSortSyncManager { - private pubkey: string; - private relayUrl: string; - private debounceTimer: number | null = null; - private lastRemoteCreatedAt: number; - private pendingStore: ChannelSortStore | null = null; - private lastPublishedStore: ChannelSortStore | null = null; - private destroyed = false; - - constructor(pubkey: string, relayUrl: string) { - this.pubkey = pubkey; - this.relayUrl = relayUrl; - this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); - } - - async fetchRemoteSortPrefs(): Promise> { - try { - const events = await relayClient.fetchEvents({ - kinds: [KIND_CHANNEL_SORT], - authors: [this.pubkey], - "#d": [D_TAG], - limit: 1, - }); - if (events.length === 0 || events[0].pubkey !== this.pubkey) { - return { status: "absent" }; - } - const event = events[0]; - this.recordRemoteHead(event.created_at); - const result = await decryptAndParse(event); - if (!result) { - return { status: "failed", createdAt: event.created_at }; - } - return { - status: "found", - data: result, - createdAt: result.createdAt, - eventId: result.eventId, - }; - } catch { - return { status: "failed" }; - } - } - - private recordRemoteHead(createdAt: number): void { - if (createdAt > this.lastRemoteCreatedAt) { - this.lastRemoteCreatedAt = createdAt; - } - advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt); - } - - cancelPendingPublish(): void { - if (this.debounceTimer !== null) { - window.clearTimeout(this.debounceTimer); - this.debounceTimer = null; - } - } - - getPendingStore(): ChannelSortStore | null { - return this.pendingStore; - } - - publishSortPrefs(store: ChannelSortStore): void { - this.pendingStore = store; - if (this.debounceTimer !== null) { - window.clearTimeout(this.debounceTimer); - } - this.debounceTimer = window.setTimeout(() => { - this.debounceTimer = null; - void this.doPublish(store); - }, DEBOUNCE_MS); - } - - private async fetchOwnBlobBeforePublish( - store: ChannelSortStore, - ): Promise { - try { - const events = await relayClient.fetchEvents({ - kinds: [KIND_CHANNEL_SORT], - authors: [this.pubkey], - "#d": [D_TAG], - limit: 1, - }); - if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; - const event = events[0]; - // Snapshot the watermark before advancing it: after recordRemoteHead - // runs, lastRemoteCreatedAt equals event.created_at, so the LWW - // comparison remote.createdAt > lastRemoteCreatedAt would always be - // false and silently suppress the merge. - const headBeforeFetch = this.lastRemoteCreatedAt; - this.recordRemoteHead(event.created_at); - const remote = await decryptAndParse(event); - if (!remote) return store; - // Sort prefs use whole-blob LWW: take whichever is newer - if (remote.createdAt > headBeforeFetch) { - return remote.store; - } - return store; - } catch { - return store; - } - } - - private isIdenticalToLastPublished(store: ChannelSortStore): boolean { - if (!this.lastPublishedStore) return false; - const lastGroups = this.lastPublishedStore.groups; - const currentGroups = store.groups; - const lastKeys = Object.keys(lastGroups); - const currentKeys = Object.keys(currentGroups); - if (lastKeys.length !== currentKeys.length) return false; - for (const key of currentKeys) { - if (lastGroups[key] !== currentGroups[key]) return false; - } - return true; - } - - private async doPublish(store: ChannelSortStore): Promise { - try { - const merged = await this.fetchOwnBlobBeforePublish(store); - // Guard: manager may have been destroyed while fetchOwnBlobBeforePublish - // was awaited (community switch during in-flight fetch). If so, abort - // before touching the relay. - if (this.destroyed) return; - if (this.isIdenticalToLastPublished(merged)) { - this.pendingStore = null; - return; - } - const payload = { - version: 1, - groups: merged.groups, - }; - const ciphertext = await nip44EncryptToSelf(JSON.stringify(payload)); - const createdAt = Math.max( - Math.floor(Date.now() / 1_000), - this.lastRemoteCreatedAt + 1, - ); - const event = await signRelayEvent({ - kind: KIND_CHANNEL_SORT, - content: ciphertext, - createdAt, - tags: [ - ["d", D_TAG], - ["t", D_TAG], // relay discoverability; not used in our filters - ], - }); - // Final guard immediately before the network call — sign/encrypt are - // synchronous-ish but cheap; the relay socket may have moved to a - // different community by the time we reach this point. - if (this.destroyed) return; - await relayClient.publishEvent( - event, - "Timed out publishing channel sort preferences.", - "Failed to publish channel sort preferences.", - ); - this.recordRemoteHead(event.created_at); - this.lastPublishedStore = merged; - this.pendingStore = null; - } catch (error) { - console.warn("[channelSortSync] publish failed:", error); - } - } - - async subscribeToSortPrefs( - onUpdate: (remote: RemoteSortPrefs) => void, - ): Promise<() => Promise> { - return relayClient.subscribeLive( - { - kinds: [KIND_CHANNEL_SORT], - authors: [this.pubkey], - "#d": [D_TAG], - limit: 0, - }, - (event: RelayEvent) => { - if (event.pubkey !== this.pubkey) return; - // Record the raw head before decrypt so an undecryptable live event - // still advances the watermark and blocks future seed-publish. - this.recordRemoteHead(event.created_at); - void decryptAndParse(event).then((result) => { - if (result) { - onUpdate(result); - } - }); - }, + const g: Tree = Object.fromEntries( + Object.entries(store.groups).map(([key, mode]) => [key, stamp(mode)]), ); - } - - /** - * Fetches the remote blob on first mount, records the remote head, and - * delegates the seed/hold/apply-remote decision to `runBootstrap`. - */ - async bootstrap(localStore: ChannelSortStore) { - const fetchResult = await this.fetchRemoteSortPrefs(); - return runBootstrap({ - fetchResult, - lastHead: this.lastRemoteCreatedAt, - localStore, - isLocalNonEmpty: (s) => Object.keys(s.groups).length > 0, - publishFn: (s) => this.publishSortPrefs(s), - }); - } - - destroy(): void { - // Cancel any pending publish and mark this manager as destroyed so any - // in-flight doPublish() calls abort before reaching relayClient. - // Pending debounce-window changes are intentionally dropped: flushing - // could publish relay A's sort prefs to relay B via the shared relayClient - // singleton. On return, bootstrap's found path whole-blob-replaces from - // remote, so any dropped pending edit is lost. - this.destroyed = true; - this.cancelPendingPublish(); - this.pendingStore = null; - } -} + return { g }; + }, + project: projectSort, + withinLimits: (p) => + Object.keys(p.groups as object).length <= MAX_CHANNEL_SORT_GROUPS, +}; diff --git a/desktop/src/features/sidebar/lib/channelStarsSync.ts b/desktop/src/features/sidebar/lib/channelStarsSync.ts index a5abec03fb..a07f1a68d6 100644 --- a/desktop/src/features/sidebar/lib/channelStarsSync.ts +++ b/desktop/src/features/sidebar/lib/channelStarsSync.ts @@ -46,6 +46,11 @@ export class ChannelStarSyncManager { private lastRemoteCreatedAt: number; private pendingStore: ChannelStarStore | null = null; private lastPublishedStore: ChannelStarStore | null = null; + /** The bootstrap first-copy seed; retired once any relay head is observed. */ + private seedStore: ChannelStarStore | null = null; + /** Decoded heads, LWW-merged within the 500-entry bound, for each attempt. */ + private remoteStore: ChannelStarStore | null = null; + private remoteRev = 0; private destroyed = false; constructor(pubkey: string, relayUrl: string) { @@ -71,6 +76,7 @@ export class ChannelStarSyncManager { if (!result) { return { status: "failed", createdAt: event.created_at }; } + this.remember(result.store); return { status: "found", data: result, @@ -87,6 +93,22 @@ export class ChannelStarSyncManager { this.lastRemoteCreatedAt = createdAt; } advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt); + // A seed only fills a relay believed empty; any head supersedes it, even + // one whose payload a concurrent reader drops or cannot decrypt. + if (this.seedStore && this.pendingStore === this.seedStore) { + this.cancelPendingStarPublish(); + this.pendingStore = null; + } + this.seedStore = null; + } + + private remember(store: ChannelStarStore): void { + const next = this.remoteStore + ? mergeStores(this.remoteStore, store) + : store; + // Only a changed winner invalidates in-flight attempts. + if (!this.sameEntries(this.remoteStore, next)) this.remoteRev++; + this.remoteStore = next; } cancelPendingStarPublish(): void { @@ -127,19 +149,21 @@ export class ChannelStarSyncManager { this.recordRemoteHead(event.created_at); const remote = await decryptAndParse(event); if (!remote) return store; - return mergeStores(store, remote.store); + this.remember(remote.store); + return store; } catch { return store; } } - private isIdenticalToLastPublished(store: ChannelStarStore): boolean { - if (!this.lastPublishedStore) return false; - const lastKeys = Object.keys(this.lastPublishedStore.channels); + /** Same winners (IDs, `starred`, `updatedAt`), ignoring key order. */ + private sameEntries(prev: ChannelStarStore | null, store: ChannelStarStore) { + if (!prev) return false; + const lastKeys = Object.keys(prev.channels); const currentKeys = Object.keys(store.channels); if (lastKeys.length !== currentKeys.length) return false; for (const key of currentKeys) { - const last = this.lastPublishedStore.channels[key]; + const last = prev.channels[key]; const current = store.channels[key]; if ( !last || @@ -152,14 +176,29 @@ export class ChannelStarSyncManager { } private async doPublish(store: ChannelStarStore): Promise { + // A seed retired while acquired (preflight, crypto or socket wait) aborts. + const seed = store === this.seedStore; + let rev = this.remoteRev; // a changed winner before dispatch voids it + const stale = () => + this.destroyed || + (seed && this.seedStore !== store) || + rev !== this.remoteRev; + // Only the attempt that still owns `pendingStore` may clear it. + const release = () => { + if (this.pendingStore === store) this.pendingStore = null; + }; try { - const merged = await this.fetchOwnBlobBeforePublish(store); + const pre = await this.fetchOwnBlobBeforePublish(store); + const merged = this.remoteStore + ? mergeStores(pre, this.remoteStore) + : pre; + rev = this.remoteRev; // Guard: manager may have been destroyed while fetchOwnBlobBeforePublish // was awaited (community switch during in-flight fetch). If so, abort // before touching the relay. - if (this.destroyed) return; - if (this.isIdenticalToLastPublished(merged)) { - this.pendingStore = null; + if (stale()) return; + if (this.sameEntries(this.lastPublishedStore, merged)) { + release(); return; } const payload = { @@ -180,17 +219,21 @@ export class ChannelStarSyncManager { ["t", D_TAG], // relay discoverability; not used in our filters ], }); - if (this.destroyed) return; + if (stale()) return; await relayClient.publishEvent( event, "Timed out publishing channel stars.", "Failed to publish channel stars.", + () => !stale(), ); this.recordRemoteHead(event.created_at); this.lastPublishedStore = merged; - this.pendingStore = null; + release(); } catch (error) { console.warn("[channelStarsSync] publish failed:", error); + } finally { + const owned = !this.destroyed && this.pendingStore === store; + if (owned && rev !== this.remoteRev) this.publishStars(store); } } @@ -210,7 +253,8 @@ export class ChannelStarSyncManager { // still advances the watermark and blocks future seed-publish. this.recordRemoteHead(event.created_at); void decryptAndParse(event).then((result) => { - if (result) { + if (result && !this.destroyed) { + this.remember(result.store); onUpdate(result); } }); @@ -229,7 +273,10 @@ export class ChannelStarSyncManager { lastHead: this.lastRemoteCreatedAt, localStore, isLocalNonEmpty: (s) => Object.keys(s.channels).length > 0, - publishFn: (s) => this.publishStars(s), + publishFn: (s) => { + this.seedStore = s; + this.publishStars(s); + }, }); } diff --git a/desktop/src/features/sidebar/lib/sidebarLaneHooks.test.mjs b/desktop/src/features/sidebar/lib/sidebarLaneHooks.test.mjs new file mode 100644 index 0000000000..e3d19a0d3b --- /dev/null +++ b/desktop/src/features/sidebar/lib/sidebarLaneHooks.test.mjs @@ -0,0 +1,572 @@ +import assert from "node:assert/strict"; +import { after, before, beforeEach, mock, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { url: "http://localhost" }); +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + window: dom.window, + IS_REACT_ACT_ENVIRONMENT: true, + }); +}); +after(() => dom.window.close()); + +const PK = "f".repeat(64); +const RELAY = "wss://r.test"; +let fx; +let seq = 0; +const ev = (dTag, json, createdAt) => ({ + id: String(++seq).padStart(64, "0"), + pubkey: PK, + kind: 30078, + created_at: createdAt, + content: JSON.stringify(json), + tags: [["d", dTag]], + sig: "s", +}); + +async function harness() { + const rtl = await import("@testing-library/react"); + const { relayClient } = await import("@/shared/api/relayClient"); + mock.method(relayClient, "fetchEvents", async (filter) => { + if (fx.onFetch) return fx.onFetch(); + const head = fx.heads[filter["#d"][0]]; + if (fx.gate) { + fx.gated = (fx.gated ?? 0) + 1; // a fetch entered the gated preflight + await fx.gate; + } + return head ? [head] : []; + }); + mock.method(relayClient, "subscribeLive", async (_f, onEvent) => { + fx.live = onEvent; + return async () => {}; + }); + mock.method(relayClient, "subscribeToReconnects", (fn) => { + fx.reconnect = fn; + return () => {}; + }); + mock.method(relayClient, "publishEvent", async (event, _t, _e, isCurrent) => { + fx.attempts.push(event); // the manager reached the send boundary + if (fx.holdPublish) await fx.holdPublish; + if (isCurrent && !isCurrent()) throw new Error("canceled"); + fx.published.push(event); + const d = event.tags[0][1]; // relay rule: newer wins, ties to lower id + const [a, b] = [event, fx.heads[d]]; + if ( + !b || + a.created_at > b.created_at || + (a.created_at === b.created_at && a.id < b.id) + ) + fx.heads[d] = event; + }); + mock.method(console, "warn", () => {}); + window.__TAURI_INTERNALS__ = { + invoke: async (cmd, args) => { + if (cmd === "nip44_encrypt_to_self") + return Promise.resolve(fx.holdCrypto).then(() => args.plaintext); + if (cmd === "nip44_decrypt_from_self") return args.ciphertext; + if (cmd === "sign_event") + return JSON.stringify({ + ...ev(args.tags[0][1], JSON.parse(args.content), args.createdAt), + content: args.content, + }); + throw new Error(`unmocked ${cmd}`); + }, + }; + // Advance fake time in steps, letting async work between timers settle. + const advance = async (ms) => { + for (let t = 0; t < ms; t += 1_000) { + await rtl.act(async () => { + mock.timers.tick(1_000); + for (let i = 0; i < 10; i++) await new Promise(setImmediate); + }); + } + }; + return { ...rtl, advance }; +} + +beforeEach(() => { + window.localStorage.clear(); + mock.restoreAll(); + mock.timers.reset(); + mock.timers.enable({ apis: ["setTimeout", "Date"], now: 1e12 }); + fx = { heads: {}, published: [], attempts: [], reconnect: null }; +}); + +for (const [name, modPath, hookName, dTag, idsKey] of [ + [ + "stars", + "./useChannelStars.ts", + "useChannelStars", + "channel-stars", + "starredChannelIds", + ], + [ + "mutes", + "./useChannelMutes.ts", + "useChannelMutes", + "channel-mutes", + "mutedChannelIds", + ], +]) { + test(`${name}: the real hook recovers a missed head on the 5 s and steady 60 s ticks`, async () => { + const { renderHook, advance, cleanup } = await harness(); + const hook = (await import(modPath))[hookName]; + const entry = (ids) => + Object.fromEntries( + ids.map((id) => [ + id, + { [name === "stars" ? "starred" : "muted"]: true, updatedAt: 1 }, + ]), + ); + const { result } = renderHook(() => hook(PK, RELAY)); + await advance(1_000); + assert.equal(result.current[idsKey].size, 0); + fx.heads[dTag] = ev(dTag, { version: 1, channels: entry(["c1"]) }, 100); + await advance(5_000); + assert.deepEqual([...result.current[idsKey]], ["c1"]); + await advance(10_000 + 30_000); // back-off climbs to its steady 60 s + fx.heads[dTag] = ev( + dTag, + { version: 1, channels: entry(["c1", "c2"]) }, + 200, + ); + await advance(60_000); + assert.deepEqual([...result.current[idsKey]].sort(), ["c1", "c2"]); + cleanup(); + }); +} + +const deferred = () => { + let resolve; + let reject; + const promise = new Promise((res, rej) => ([resolve, reject] = [res, rej])); + return { promise, resolve, reject }; +}; + +for (const [name, modPath, hookName, dTag, idsKey, verb, field] of [ + [ + "stars", + "./useChannelStars.ts", + "useChannelStars", + "channel-stars", + "starredChannelIds", + "starChannel", + "starred", + ], + [ + "mutes", + "./useChannelMutes.ts", + "useChannelMutes", + "channel-mutes", + "mutedChannelIds", + "muteChannel", + "muted", + ], +]) { + const payload = (ids, updatedAt = 1) => ({ + version: 1, + channels: Object.fromEntries( + ids.map((id) => [id, { [field]: true, updatedAt }]), + ), + }); + const headIds = () => + Object.entries(JSON.parse(fx.heads[dTag].content).channels) + .filter(([, v]) => v[field]) + .map(([id]) => id) + .sort(); + const visible = () => + document.dispatchEvent(new window.Event("visibilitychange")); + const setup = async () => { + Object.defineProperty(document, "visibilityState", { + value: "visible", + configurable: true, + }); + const h = await harness(); + const hook = (await import(modPath))[hookName]; + fx.storageKey = ( + await import(`./channel${name === "stars" ? "Stars" : "Mutes"}Storage.ts`) + ).storageKey; + return { ...h, hook }; + }; + // B (or the union) must hold in the UI and the persisted cache alike. + const shows = (result, ids, msg) => { + const cached = JSON.parse(window.localStorage.getItem(fx.storageKey(PK))); + assert.deepEqual([...result.current[idsKey]].sort(), ids, msg); + const on = Object.entries(cached.channels).filter(([, v]) => v[field]); + assert.deepEqual(on.map(([id]) => id).sort(), ids, msg); + }; + // A later authoritative head still applies: recovery kept polling. + const resumes = async ({ act, advance }, result, ids) => { + fx.heads[dTag] = ev(dTag, payload([...ids, "c7"], 9), 2e9); + await act(async () => visible()); + await advance(1_000); + shows(result, [...ids, "c7"].sort(), "polling resumed"); + }; + + test(`${name}: an older ACK leaves a newer edit pending through recovery`, async () => { + const h = await setup(); + const { renderHook, act, advance, cleanup, hook } = h; + const { result } = renderHook(() => hook(PK, RELAY)); + await advance(1_000); + act(() => result.current[verb]("c1")); // edit A + const ack = deferred(); + fx.holdPublish = ack.promise; + await advance(2_000); // A is at the socket, its ACK held + act(() => result.current[verb]("c2")); // edit B, deadline +2 s + fx.holdPublish = null; + await act(async () => ack.resolve()); + assert.deepEqual(headIds(), ["c1"], "A's ACK exit ran before recovery"); + await act(async () => visible()); // recovery before B's deadline + shows(result, ["c1", "c2"], "B survives the recovery"); + await advance(3_000); + assert.deepEqual(headIds(), ["c1", "c2"], "B reached the relay"); + shows(result, ["c1", "c2"], "B settled"); + await resumes(h, result, ["c1", "c2"]); + cleanup(); + }); + + test(`${name}: an identical-payload exit leaves a newer edit pending through recovery`, async () => { + const h = await setup(); + const { renderHook, act, advance, cleanup, hook } = h; + const { result } = renderHook(() => hook(PK, RELAY)); + await advance(1_000); + act(() => result.current[verb]("c1")); // A + const ack = deferred(); + fx.holdPublish = ack.promise; + await advance(2_000); + await act(async () => fx.reconnect()); // re-queues pending A + fx.holdPublish = null; + await act(async () => ack.resolve()); + const gate = deferred(); + fx.gate = gate.promise; + await advance(2_000); // A's repeat waits in preflight + assert.equal(fx.gated, 1, "A's repeat entered its gated preflight"); + act(() => result.current[verb]("c2")); // B + fx.gate = null; + await act(async () => gate.resolve()); // repeat of A exits as identical + assert.equal(fx.attempts.length, 1, "A's repeat sent nothing"); + await act(async () => visible()); + shows(result, ["c1", "c2"], "B survives the recovery"); + await advance(3_000); + assert.deepEqual(headIds(), ["c1", "c2"], "B reached the relay"); + shows(result, ["c1", "c2"], "B settled"); + await resumes(h, result, ["c1", "c2"]); + cleanup(); + }); + + const stages = ["absent", "failed", "found", "crypto", "socket"]; + const edits = ["edit", "edit-absent", "edit-failed", "edit2"]; + const mid = ["edit-late", "edit-crypto", "edit-stale"]; // decoded mid-attempt + edits.push(...mid, "edit-conflict"); + for (const stage of [...stages, ...edits, "ack"]) { + const what = + { + edit: "a genuine edit survives the seed's retirement", + "edit-absent": "an edit whose preflight is absent keeps R", + "edit-failed": "an edit whose preflight fails keeps R", + edit2: "a second edit whose preflight fails keeps R", + "edit-late": "R decoded during a failing preflight is kept", + "edit-crypto": "R decoded during encryption requeues the edit", + "edit-stale": "an observation with no changed winner cancels nothing", + "edit-conflict": "R's future-dated false beats the edit's true", + ack: "the seed's own ACK leaves a genuine edit pending", + }[stage] ?? `an acquired seed yields to R (retired at ${stage})`; + test(`${name}: startup recovery; ${what}`, async () => { + const { renderHook, act, advance, cleanup, hook } = await setup(); + window.localStorage.setItem( + fx.storageKey(PK), + JSON.stringify(payload(["s1"])), + ); + const off = { [field]: false, updatedAt: 3e12 }; // future-dated winner + const channels = payload(["r1"], 2e12).channels; + if (stage === "edit-conflict") channels.c9 = off; + const R = ev(dTag, { version: 1, channels }, 1e9 + 30); // future-dated + if (stage !== "ack") fx.heads[dTag] = R; + const calls = []; + fx.onFetch = () => { + const d = deferred(); + calls.push(d); + return d.promise; + }; + const { result } = renderHook(() => hook(PK, RELAY)); + await act(async () => calls[0].resolve([])); // bootstrap: absent, seeds S + const held = deferred(); + if (mid.includes(stage)) { + const [late, early] = [stage === "edit-late", stage === "edit-stale"]; + act(() => result.current[verb]("c9")); + if (early) await act(async () => calls[1].resolve([R])); + if (!late) fx.holdCrypto = held.promise; + await advance(2_000); // the edit's preflight waits + if (!late) await act(async () => calls[2].resolve([])); + const old = ev(dTag, payload(["r1"]), 1e9 + 10); + if (early) + await act(async () => fx.live(old)); // stale copy + else await act(async () => calls[1].resolve([R])); // decodes R + if (late) calls[2].reject(new Error("x")); + await act(async () => held.resolve()); + fx.holdCrypto = null; + const sends = early ? 1 : 0; // only R's arrival voids the snapshot + if (!late) assert.equal(fx.attempts.length, sends, "fence"); + } else if (edits.includes(stage)) { + const fail = (c) => + stage === "edit-absent" ? c.resolve([]) : c.reject(new Error("x")); + act(() => result.current[verb]("c9")); + await act(async () => calls[1].resolve([R])); // recovery observes R + await advance(2_000); + const ok = stage === "edit" || stage === "edit2"; + await act(async () => (ok ? calls[2].resolve([R]) : fail(calls[2]))); + if (stage === "edit2") { + act(() => result.current[verb]("c8")); // second edit + await advance(2_000); + await act(async () => fail(calls.at(-1))); + } + } else if (stage === "ack") { + fx.holdPublish = held.promise; + await advance(2_000); // S acquired + await act(async () => calls[1].resolve([])); + await act(async () => calls[2].resolve([])); // S at the socket + act(() => result.current[verb]("c9")); // genuine edit B + fx.onFetch = fx.holdPublish = null; + await act(async () => held.resolve()); // S's own ACK + } else if (stage === "crypto" || stage === "socket") { + fx[stage === "crypto" ? "holdCrypto" : "holdPublish"] = held.promise; + await advance(2_000); // seed acquired + await act(async () => calls[2].resolve([])); // preflight: absent + await act(async () => calls[1].resolve([R])); // retire mid-stage + await act(async () => held.resolve()); + const sends = stage === "crypto" ? 0 : 1; // observable send boundary + assert.equal(fx.attempts.length, sends, `retired during ${stage}`); + } else { + await advance(2_000); // seed acquired; its preflight waits + await act(async () => calls[1].resolve([R])); // recovery observes R + if (stage === "failed") calls[2].reject(new Error("offline")); + else calls[2].resolve(stage === "found" ? [R] : []); + } + await advance(3_000); + // The later scheduled read gets whatever head the relay kept. + fx.onFetch = null; + for (const c of calls) c.resolve([fx.heads[dTag]]); + await advance(60_000); + const kept = stage.startsWith("edit") + ? [...({ edit2: ["c8", "c9"], "edit-conflict": [] }[stage] ?? ["c9"])] + .concat("r1", "s1") + .sort() + : { ack: ["c9", "s1"] }[stage]; + shows(result, kept ?? ["r1", "s1"], "UI and cache"); + const sent = fx.published.map((e) => JSON.parse(e.content).channels); + if (edits.includes(stage)) + assert.ok( + sent.every((c) => c.r1), + "no send lacks r1 (presence only)", + ); + if (!kept) { + assert.equal(fx.published.length, 0, "the seed never published"); + assert.equal(fx.heads[dTag], R, "R stays the relay head"); + } + assert.deepEqual(headIds(), kept ?? ["r1"]); + const head = JSON.parse(fx.heads[dTag].content).channels; + const cache = window.localStorage.getItem(fx.storageKey(PK)); + const both = [head, JSON.parse(cache).channels]; // relay and cache + for (const ch of edits.includes(stage) ? both : []) { + assert.deepEqual(ch.r1, { [field]: true, updatedAt: 2e12 }); + if (stage === "edit-conflict") assert.deepEqual(ch.c9, off); + } + cleanup(); // a fresh reader consumes only the retained head + window.localStorage.clear(); + const fresh = renderHook(() => hook(PK, RELAY)); + await advance(1_000); + assert.deepEqual( + [...fresh.result.current[idsKey]].sort(), + kept ?? ["r1"], + ); + cleanup(); + }); + } +} + +test("sections hook: 60 s tick, reconnect, cross-tab, pending edit and in-flight unmount", async () => { + const { renderHook, act, advance, cleanup } = await harness(); + const { useChannelSections } = await import("./useChannelSections.ts"); + const { storageKey } = await import("./channelSectionsStorage.ts"); + const legacy = (names) => ({ + version: 1, + sections: names.map((n, order) => ({ id: n, name: n, order })), + assignments: {}, + }); + fx.heads["channel-sections"] = ev("channel-sections", legacy(["a"]), 100); + const { result, unmount } = renderHook(() => useChannelSections(PK, RELAY)); + await advance(1_000); + const names = () => result.current.sections.map((s) => s.name); + assert.deepEqual(names(), ["a"]); + + // Missed live event after the back-off reached 60 s (ticks at 5/15/45 s): + // only the steady tick at 105 s picks it up. + await advance(45_000); + fx.heads["channel-sections"] = ev( + "channel-sections", + legacy(["a", "b"]), + 200, + ); + await advance(58_000); + assert.deepEqual(names(), ["a"], "not yet: no tick before 105 s"); + await advance(2_000); + assert.deepEqual(names(), ["a", "b"]); + + // Reconnect re-reads. + fx.heads["channel-sections"] = ev( + "channel-sections", + legacy(["a", "b", "c"]), + 300, + ); + await act(async () => fx.reconnect()); + await advance(1_000); + assert.deepEqual(names(), ["a", "b", "c"]); + + // Another tab's write merges (never replaces). + const other = legacy(["d"]); + await act(async () => + window.dispatchEvent( + new window.StorageEvent("storage", { + key: storageKey(PK, RELAY), + newValue: JSON.stringify(other), + }), + ), + ); + assert.deepEqual(names().sort(), ["a", "b", "c", "d"]); + + // A read (here, reconnect) during the edit debounce merges but cannot + // publish early or shorten the debounce. + const before = fx.published.length; + act(() => void result.current.createSection("e")); + fx.heads["channel-sections"] = ev( + "channel-sections", + legacy(["a", "b", "c", "f"]), + 400, + ); + await act(async () => fx.reconnect()); + await advance(1_000); + assert.equal(fx.published.length, before, "held by the debounce"); + assert.ok(names().includes("f") && names().includes("e")); + + // Unmount while the publish is in flight: nothing reaches the socket. + let release; + fx.holdPublish = new Promise((r) => (release = r)); + await advance(2_000); + unmount(); + release(); + await advance(1_000); + assert.equal(fx.published.length, before); + cleanup(); +}); + +test("sections hook: create appends past gaps; reorder completes against the live list", async () => { + const { renderHook, act, advance, cleanup } = await harness(); + const { useChannelSections } = await import("./useChannelSections.ts"); + const { storageKey } = await import("./channelSectionsStorage.ts"); + const { result } = renderHook(() => useChannelSections(PK, RELAY)); + await advance(1_000); + const names = () => result.current.sections.map((s) => s.name); + const ids = {}; + for (const n of ["a", "b", "c"]) + act(() => { + ids[n] = result.current.createSection(n).id; + }); + act(() => result.current.deleteSection(ids.b)); // canonical ranks 0 and 2 + act(() => void result.current.createSection("d")); + assert.deepEqual(names(), ["a", "c", "d"], "new section sorts last"); + + // A section merged in from another tab after the drag snapshot was taken. + const snapshot = result.current.sections.map((s) => s.id); + await act(async () => + window.dispatchEvent( + new window.StorageEvent("storage", { + key: storageKey(PK, RELAY), + newValue: JSON.stringify({ + version: 1, + sections: [{ id: "x", name: "x", order: 9 }], + assignments: {}, + }), + }), + ), + ); + act(() => + result.current.reorderSections([...snapshot].reverse().concat("gone")), + ); + assert.deepEqual(names(), ["d", "c", "a", "x"]); + const orders = JSON.parse(window.localStorage.getItem(storageKey(PK, RELAY))) + .meta.s; + assert.ok(orders.x.order, "the missing live section got an order register"); + cleanup(); +}); + +const UPGRADE_LANES = [ + { + name: "sections", + dTag: "channel-sections", + load: async () => [ + (await import("./useChannelSections.ts")).useChannelSections, + (await import("./channelSectionsStorage.ts")).storageKey, + ], + legacy: (name) => ({ + version: 1, + sections: [{ id: "x", name, order: 0 }], + assignments: {}, + }), + ui: (r) => r.sections[0]?.name, + fromDoc: (doc) => doc.sections[0]?.name, + edit: (r, v) => r.renameSection("x", v), + values: ["Eng", "Platform", "Mine"], + }, + { + name: "sort", + dTag: "channel-sort", + load: async () => [ + (await import("./useChannelSortPreference.ts")).useChannelSortPreference, + (await import("./channelSortPreference.ts")).storageKey, + ], + legacy: (mode) => ({ version: 1, groups: { channels: mode } }), + ui: (r) => r.sortModeFor("channels"), + fromDoc: (doc) => doc.groups.channels, + edit: (r, v) => { + r.setSortModeFor("channels", "alpha"); // authored, then back + r.setSortModeFor("channels", v); + }, + values: ["recent", "alpha", "recent"], + }, +]; + +for (const L of UPGRADE_LANES) { + for (const edited of [false, true]) { + const what = edited + ? "a genuine local edit still beats the relay" + : "a cache-only import yields to the relay head"; + test(`${L.name} first upgrade: ${what}`, async () => { + const { renderHook, act, advance, cleanup } = await harness(); + const [hook, key] = await L.load(); + const [cached, remote, mine] = L.values; + window.localStorage.setItem( + key(PK, RELAY), + JSON.stringify(L.legacy(cached)), + ); + fx.heads[L.dTag] = ev(L.dTag, L.legacy(remote), 100); + let open; + fx.gate = new Promise((r) => (open = r)); + const { result } = renderHook(() => hook(PK, RELAY)); + if (edited) act(() => L.edit(result.current, mine)); + fx.gate = null; + open(); + await advance(5_000); + const want = edited ? mine : remote; + assert.equal(L.ui(result.current), want, "UI"); + const cache = JSON.parse(window.localStorage.getItem(key(PK, RELAY))); + assert.equal(L.fromDoc(cache), want, "cache"); + const head = JSON.parse(fx.heads[L.dTag].content); + assert.equal(L.fromDoc(head), want, "relay head"); + assert.ok(head.meta, "a fresh reader gets the registers"); + cleanup(); + }); + } +} diff --git a/desktop/src/features/sidebar/lib/sidebarLaneReconciler.ts b/desktop/src/features/sidebar/lib/sidebarLaneReconciler.ts new file mode 100644 index 0000000000..a36e76d091 --- /dev/null +++ b/desktop/src/features/sidebar/lib/sidebarLaneReconciler.ts @@ -0,0 +1,387 @@ +import * as React from "react"; + +import { relayClient } from "@/shared/api/relayClient"; +import { PublishCanceledError } from "@/shared/api/relayEventPublisher"; +import { + nip44DecryptFromSelf, + nip44EncryptToSelf, + signRelayEvent, +} from "@/shared/api/tauri"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_CHANNEL_SECTIONS } from "@/shared/constants/kinds"; +import { + decodeDoc, + encodeDoc, + LaneStore, + type LaneCodec, +} from "./sidebarLaneStore"; +import { canonical, isEmptyTree, type Tree } from "./sidebarLwwMap"; +import { advanceWatermark, readWatermark } from "./sidebarSyncWatermark"; +import { useStaleReaderRecovery } from "./useStaleReaderRecovery"; + +/** Relay event size ceiling shared by every sidebar blob. */ +const MAX_CIPHERTEXT_BYTES = 65_535; +const DEBOUNCE_MS = 2_000; +const BACKOFF_MS = [5_000, 10_000, 30_000, 60_000, 60_000]; + +export type Lane = LaneCodec & { + dTag: string; + /** False when the projected payload exceeds today's count caps. */ + withinLimits: (projection: Record) => boolean; +}; + +type Status = "unknown" | "empty" | "decoding" | "decoded" | "unreadable"; +type Head = { + id: string; + createdAt: number; + status: Status; + digest: string | null; +}; + +/** `a` beats `b`: later `created_at`, then lower id (the relay's replaceable winner). */ +const beats = (a: RelayEvent, b: Head) => + a.created_at > b.createdAt || (a.created_at === b.createdAt && a.id < b.id); + +/** + * Converges one lane's local tree with its relay head. Publishes only when the + * decoded head's canonical bytes differ from the local tree's, never over an + * undecoded or unreadable head, and never over an absence after a head has + * been seen in this scope (the watermark). Every publish attempt waits for + * `notBefore` (edit debounce or failure backoff); reads and merges do not. + */ +export class LaneReconciler { + head: Head = { id: "", createdAt: 0, status: "unknown", digest: null }; + private lastHead: number; + private attempt: object | null = null; + private recheck = false; + private failures = 0; + private notBefore = 0; + private observations = 0; + private timer: ReturnType | null = null; + private destroyed = false; + + private readonly lane: Lane; + private readonly store: LaneStore; + private readonly pubkey: string; + private readonly relayUrl: string; + + constructor(lane: Lane, store: LaneStore, pubkey: string, relayUrl: string) { + this.lane = lane; + this.store = store; + this.pubkey = pubkey; + this.relayUrl = relayUrl; + this.lastHead = readWatermark(pubkey, lane.dTag, relayUrl); + } + + filter(limit: number) { + return { + kinds: [KIND_CHANNEL_SECTIONS], + authors: [this.pubkey], + "#d": [this.lane.dTag], + limit, + }; + } + + /** Records a raw head; one that beats the current head holds publishing until decoded. */ + private observe(event: RelayEvent): void { + this.observations++; + this.lastHead = Math.max(this.lastHead, event.created_at); + advanceWatermark( + this.pubkey, + this.lane.dTag, + this.relayUrl, + event.created_at, + ); + if (!beats(event, this.head)) return; + this.head = { + id: event.id, + createdAt: event.created_at, + status: "decoding", + digest: null, + }; + } + + /** Decodes and merges `event`; its status lands only while it is still the head. */ + async ingest(event: RelayEvent): Promise { + if (event.pubkey !== this.pubkey) return; + this.observe(event); + const mine = this.observations; + let json: unknown = null; + try { + json = JSON.parse(await nip44DecryptFromSelf(event.content)); + } catch { + json = null; + } + if (this.destroyed) return; + const doc = decodeDoc(this.lane, json, event.created_at * 1_000); + if (doc) this.store.merge(doc.tree, doc.legacy); + // Only the newest observation (event or absence) may set eligibility; a + // stale decode still merges its content above. + if (this.head.id === event.id && this.observations === mine) { + this.head = doc + ? { ...this.head, status: "decoded", digest: canonical(json) } + : { ...this.head, status: "unreadable", digest: null }; + } + this.reconcile(); + } + + /** One relay read. Throws on transport failure. */ + async read(): Promise { + const seen = this.observations; + const [event] = await relayClient.fetchEvents(this.filter(1)); + if (this.destroyed) return; + if (event && event.pubkey === this.pubkey) return this.ingest(event); + // Absence counts only if nothing was observed while it was in flight. It + // permits a first copy for a genuinely new scope; after a head has been + // seen it demotes the head and holds until a readable head returns. + if (seen === this.observations) { + this.observations++; + const status = this.lastHead === 0 ? "empty" : "unknown"; + this.head = { ...this.head, status, digest: null }; + } + this.reconcile(); + } + + reconcile(): void { + if (this.destroyed) return; + const wait = this.notBefore - Date.now(); + if (wait > 0) { + this.wake(wait); // hold; re-read at the deadline + return; + } + if (this.attempt) { + this.recheck = true; + return; + } + const { status, digest } = this.head; + if (status !== "empty" && status !== "decoded") return; // hold; retried on cadence + const tree = this.store.get(); + const settled = + status === "empty" + ? isEmptyTree(tree) + : digest === canonical(encodeDoc(this.lane, tree)); + if (settled) { + this.failures = 0; + return; + } + void this.runAttempt(); + } + + /** Re-reads after `delay`. Never shortens a publish hold (`defer`). */ + wake(delay = 0): void { + if (this.destroyed) return; + if (this.timer !== null) clearTimeout(this.timer); + this.timer = setTimeout(() => { + this.timer = null; + this.read().catch(() => this.backoff()); + }, delay); + } + + /** Holds publishing for at least `delay`; local edits coalesce behind it. */ + defer(delay = DEBOUNCE_MS): void { + this.notBefore = Math.max(this.notBefore, Date.now() + delay); + this.wake(delay); + } + + private backoff(): void { + const delay = BACKOFF_MS[ + Math.min(this.failures, BACKOFF_MS.length - 1) + ] as number; + this.failures++; + this.defer(delay); + } + + private async runAttempt(): Promise { + const token = {}; + this.attempt = token; + let outcome: "settled" | "acked" | "held" | "failed" = "failed"; + const held = () => Date.now() < this.notBefore; + let head = this.head; + let tree: Tree = this.store.get(); + try { + await this.read(); // preflight: merge the current head first + head = this.head; + tree = this.store.get(); + if (held()) { + outcome = "held"; // a deadline arrived during preflight + return; + } + const doc = encodeDoc(this.lane, tree); + const empty = head.status === "empty" && isEmptyTree(tree); + if (head.status !== "empty" && head.status !== "decoded") { + outcome = "settled"; // hold: the recovery cadence re-reads + return; + } + if ( + empty || + head.digest === canonical(doc) || + !this.lane.withinLimits(doc) + ) { + outcome = "settled"; // equal, or over limit: kept locally only + return; + } + const dropped = () => + this.destroyed || + held() || + this.store.get() !== tree || + this.head.id !== head.id || + this.head.status !== head.status; + // A deliberate deadline cancel keeps the deadline; stale drops back off. + // The cause is captured when the drop happens, not when it is consumed. + let heldDrop = false; + const drop = () => { + if (!dropped()) return false; + heldDrop = held(); + if (heldDrop) outcome = "held"; + return true; + }; + const content = await nip44EncryptToSelf(JSON.stringify(doc)); + if (new TextEncoder().encode(content).length > MAX_CIPHERTEXT_BYTES) { + outcome = "settled"; + return; + } + if (drop()) return; + const event = await signRelayEvent({ + kind: KIND_CHANNEL_SECTIONS, + content, + createdAt: Math.max(Math.floor(Date.now() / 1_000), head.createdAt + 1), + tags: [ + ["d", this.lane.dTag], + ["t", this.lane.dTag], // relay discoverability; not used in our filters + ], + }); + if (drop()) return; + try { + await relayClient.publishEvent( + event, + `Timed out publishing ${this.lane.dTag}.`, + `Failed to publish ${this.lane.dTag}.`, + () => !drop(), // checked again right before each socket send + ); + } catch (error) { + if (error instanceof PublishCanceledError && heldDrop) return; + if (!String((error as Error)?.message).startsWith("duplicate:")) + throw error; + } + outcome = "acked"; + this.lastHead = Math.max(this.lastHead, event.created_at); + advanceWatermark( + this.pubkey, + this.lane.dTag, + this.relayUrl, + event.created_at, + ); + } catch (error) { + console.warn(`[${this.lane.dTag}] sync attempt failed:`, error); + } finally { + if (this.attempt === token) this.attempt = null; + const recheck = this.recheck; + this.recheck = false; + // An ACK proves nothing about the head; verify with a read. Real + // failures and stale drops retry behind the backoff, never immediately. + if (outcome === "acked") this.wake(); + else if (outcome === "held") + this.wake(this.notBefore - Date.now()); // deadline kept; no retry now + else if (outcome === "failed") this.backoff(); + else if (recheck && (this.head !== head || this.store.get() !== tree)) { + this.reconcile(); + } + } + } + + destroy(): void { + this.destroyed = true; + if (this.timer !== null) clearTimeout(this.timer); + } +} + +/** + * Binds a lane to React for one pubkey + relay scope: the store's tree as + * state, and a reconciler fed by bootstrap, live events, reconnects, local + * edits, and the stale-reader recovery cadence. + */ +export function useLaneSync( + lane: Lane, + pubkey: string | undefined, + relayUrl: string | undefined, +): { tree: Tree; edit: (fn: (tree: Tree) => Tree) => void } { + const store = React.useMemo( + () => (pubkey && relayUrl ? new LaneStore(lane, pubkey, relayUrl) : null), + [lane, pubkey, relayUrl], + ); + const [reconciler, setReconciler] = React.useState( + null, + ); + const reconcilerRef = React.useRef(null); + reconcilerRef.current = reconciler; + const tree = React.useSyncExternalStore( + React.useCallback( + (fn: () => void) => store?.subscribe(fn) ?? (() => {}), + [store], + ), + () => store?.get() ?? EMPTY, + ); + + React.useEffect(() => { + if (!store || !pubkey || !relayUrl) return; + const rec = new LaneReconciler(lane, store, pubkey, relayUrl); + setReconciler(rec); + let disposed = false; + let unsubLive: (() => Promise) | null = null; + const detachTabs = store.attachCrossTab(); + const unsubReconnect = relayClient.subscribeToReconnects(() => rec.wake()); + void relayClient + .subscribeLive(rec.filter(0), (event) => { + if (!disposed) void rec.ingest(event); + }) + .then((dispose) => { + if (disposed) void dispose(); + else unsubLive = dispose; + }) + .catch(() => {}); + return () => { + disposed = true; + rec.destroy(); + detachTabs(); + unsubReconnect(); + if (unsubLive) void unsubLive(); + setReconciler(null); + }; + }, [lane, store, pubkey, relayUrl]); + + // D2: the shared recovery cadence drives reads even while an edit is + // pending; the reconciler consumes every result, so nothing is applied here. + const recoveryRead = React.useCallback(async () => { + store?.persist(); + await reconciler?.read().catch(() => {}); + return undefined; + }, [store, reconciler]); + useStaleReaderRecovery({ + enabled: reconciler !== null, + fetch: recoveryRead, + hasPending: NEVER_PENDING, + getRevision: ZERO, + makeUpdater: IDENTITY, + setStore: NOOP, + }); + + // A local edit persists immediately and publishes after the debounce; an + // edit made before the reconciler exists is picked up by its first read. + const edit = React.useCallback( + (fn: (tree: Tree) => Tree) => { + if (!store) return; + const before = store.get(); + store.transact(fn); + if (store.get() !== before) reconcilerRef.current?.defer(); + }, + [store], + ); + + return { tree, edit }; +} + +const EMPTY: Tree = Object.freeze({}) as Tree; +const NEVER_PENDING = () => false; +const ZERO = () => 0; +const IDENTITY = () => (prev: never) => prev; +const NOOP = () => {}; diff --git a/desktop/src/features/sidebar/lib/sidebarLaneStore.ts b/desktop/src/features/sidebar/lib/sidebarLaneStore.ts new file mode 100644 index 0000000000..fdbc2117d4 --- /dev/null +++ b/desktop/src/features/sidebar/lib/sidebarLaneStore.ts @@ -0,0 +1,158 @@ +import { + canonical, + isReg, + LEGACY_DEV, + mergeTrees, + validTree, + type Reg, + type Shape, + type Tree, +} from "./sidebarLwwMap"; + +/** How one sidebar preference maps between its register tree and its JSON. */ +export type LaneCodec = { + storageKey: (pubkey: string, relayUrl: string) => string; + /** Pre-relay-scoping key migrated once on first read, then removed. */ + legacyStorageKey?: (pubkey: string) => string; + /** Shape of `meta` (excluding `meta.v`). */ + shape: Shape; + /** Registers for a meta-less payload, every value stamped by `stamp`. */ + fromLegacy: (json: unknown, stamp: (value: T) => Reg) => Tree | null; + /** The legacy fields older clients read, projected from the registers. */ + project: (tree: Tree) => Record; +}; + +export type DecodedDoc = { tree: Tree; legacy: boolean }; + +/** + * Decodes a `version: 1` payload. A meta-less payload (older writer) is + * imported at version `legacyV`; the caller merges it only into missing + * leaves. Returns null when unreadable, including an unknown `meta.v`. + */ +export function decodeDoc( + codec: LaneCodec, + json: unknown, + legacyV: number, +): DecodedDoc | null { + if (typeof json !== "object" || json === null) return null; + const { version, meta } = json as { version?: unknown; meta?: unknown }; + if (version !== 1) return null; + if (meta === undefined) { + // Validated like `meta`, so legacy keys obey the same own-key rules. + const tree = validTree( + codec.fromLegacy(json, (value) => [legacyV, LEGACY_DEV, value]), + codec.shape, + ); + return tree && !isReg(tree) ? { tree, legacy: true } : null; + } + if ((meta as { v?: unknown } | null)?.v !== 1) return null; + const { v: _v, ...fields } = meta as Record; + const tree = validTree(fields, codec.shape); + return tree && !isReg(tree) ? { tree, legacy: false } : null; +} + +export function encodeDoc(codec: LaneCodec, tree: Tree) { + return { version: 1, ...codec.project(tree), meta: { v: 1, ...tree } }; +} + +/** + * The one authoritative register tree for a lane in a pubkey + relay scope. + * Every read and edit goes through `transact`; the full tree persists at rest + * (limits gate only publishing). + */ +export class LaneStore { + private tree: Tree = {}; + private dirty = false; + /** Pre-relay-scoping key to remove once the scoped copy is durable. */ + private legacyKey: string | undefined; + private listeners = new Set<() => void>(); + private readonly key: string; + + private readonly codec: LaneCodec; + + constructor(codec: LaneCodec, pubkey: string, relayUrl: string) { + this.codec = codec; + this.key = codec.storageKey(pubkey, relayUrl); + const legacyKey = codec.legacyStorageKey?.(pubkey); + const raw = this.readRaw(this.key) ?? this.readRaw(legacyKey); + // Meta-less local caches import at a stable stamp any remote beats. + const doc = raw === undefined ? null : decodeDoc(codec, raw, 1); + if (!doc) return; + this.tree = doc.tree; + this.dirty = doc.legacy; + this.legacyKey = legacyKey; + this.persist(); + } + + private readRaw(key: string | undefined): unknown { + try { + const raw = key ? window.localStorage.getItem(key) : null; + return raw === null ? undefined : JSON.parse(raw); + } catch { + return undefined; + } + } + + get = (): Tree => this.tree; + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + /** + * Applies `fn`. A tree with unchanged canonical bytes has no side effects + * beyond retrying an earlier failed write. + */ + transact(fn: (tree: Tree) => Tree): void { + const next = fn(this.tree); + if (next === this.tree || canonical(next) === canonical(this.tree)) { + this.persist(); + return; + } + this.tree = next; + this.dirty = true; + this.persist(); + for (const listener of this.listeners) listener(); + } + + merge(remote: Tree, onlyMissing = false): void { + this.transact((tree) => mergeTrees(tree, remote, onlyMissing)); + } + + /** + * Writes the tree if an earlier write failed or is outstanding, then retires + * the legacy key once the scoped copy is durable. Both retry on later calls. + */ + persist(): boolean { + try { + if (this.dirty) { + const raw = canonical(encodeDoc(this.codec, this.tree)); + window.localStorage.setItem(this.key, raw); + this.dirty = false; + } + if (this.legacyKey && this.readRaw(this.key) !== undefined) { + window.localStorage.removeItem(this.legacyKey); + this.legacyKey = undefined; + } + } catch { + // Stays pending; retried on the next transaction or recovery tick. + } + return !this.dirty; + } + + /** Merges (never replaces) another tab's copy of this scope. */ + attachCrossTab(): () => void { + const handler = (event: StorageEvent) => { + if (event.key !== this.key || event.newValue === null) return; + try { + const doc = decodeDoc(this.codec, JSON.parse(event.newValue), 1); + if (doc) this.merge(doc.tree, doc.legacy); + } catch { + // Ignore a malformed foreign write. + } + }; + window.addEventListener("storage", handler); + return () => window.removeEventListener("storage", handler); + } +} diff --git a/desktop/src/features/sidebar/lib/sidebarLaneSync.test.mjs b/desktop/src/features/sidebar/lib/sidebarLaneSync.test.mjs new file mode 100644 index 0000000000..e6a2e7a46f --- /dev/null +++ b/desktop/src/features/sidebar/lib/sidebarLaneSync.test.mjs @@ -0,0 +1,643 @@ +import assert from "node:assert/strict"; +import test, { beforeEach, mock } from "node:test"; + +import { relayClient } from "@/shared/api/relayClient"; +import { SECTIONS_LANE, projectSections } from "./channelSectionsSync.ts"; +import { SORT_LANE, projectSort } from "./channelSortSync.ts"; +import { + PublishCanceledError, + publishSessionEvent, +} from "@/shared/api/relayEventPublisher"; +import { LaneReconciler } from "./sidebarLaneReconciler.ts"; +import { LaneStore } from "./sidebarLaneStore.ts"; +import { canonical, setRegs } from "./sidebarLwwMap.ts"; + +const PK = "f".repeat(64); +const RELAY = "wss://r.test"; +const storage = new Map(); +globalThis.window ??= {}; +Object.assign(globalThis.window, { + localStorage: { + getItem: (k) => storage.get(k) ?? null, + setItem: (k, v) => { + if (fx.storageFails) throw new Error("quota"); + fx.writes++; + storage.set(k, v); + }, + removeItem: (k) => storage.delete(k), + }, + addEventListener: () => {}, + removeEventListener: () => {}, +}); + +/** Fake relay (one replaceable head), identity crypto, scripted failures. */ +let fx; +let seq = 0; +beforeEach(() => { + storage.clear(); + mock.restoreAll(); + fx = { + head: null, + published: [], + fetchFails: false, + publish: "ok", + storageFails: false, + writes: 0, + badDecrypt: new Set(), + }; + globalThis.window.__TAURI_INTERNALS__ = { + invoke: async (cmd, args) => { + if (cmd === "nip44_encrypt_to_self") return args.plaintext; + if (cmd === "nip44_decrypt_from_self") { + if (fx.badDecrypt.has(args.ciphertext)) throw new Error("bad"); + if (fx.holdDecrypt && --fx.holdAfter < 0) await fx.holdDecrypt; + return args.ciphertext; + } + if (cmd === "sign_event") + return JSON.stringify(ev(args.content, args.createdAt)); + throw new Error(`unmocked ${cmd}`); + }, + }; + mock.method(relayClient, "fetchEvents", async () => { + const result = fx.head ? [fx.head] : []; // snapshot at request time + if (fx.gate) await fx.gate; + if (fx.fetchFails) throw new Error("offline"); + return result; + }); + mock.method(console, "warn", () => {}); + mock.method(relayClient, "publishEvent", async (event, _t, _e, isCurrent) => { + fx.beforeSend?.(); + if (!isCurrent()) throw (await fx.consume) ?? new PublishCanceledError(); + fx.published.push(event); + if (fx.publish === "cancelText") { + fx.publish = "ok"; + fx.afterSend(); + throw new Error(new PublishCanceledError().message); // a relay's text + } + if (fx.publish === "timeout") throw new Error("Timed out"); + if (fx.publish === "reject") throw new Error("blocked: nope"); + if (fx.publish === "duplicate") throw new Error("duplicate: have it"); + if (fx.publish !== "lost") fx.head = event; + }); +}); + +function ev(content, createdAt) { + seq++; + return { + id: String(seq).padStart(64, "0"), + pubkey: PK, + kind: 30078, + created_at: createdAt, + content, + tags: [], + sig: "s", + }; +} + +function device(lane) { + const store = new LaneStore(lane, PK, RELAY); + const rec = new LaneReconciler(lane, store, PK, RELAY); + rec.wake = () => {}; // timers are driven explicitly via read() + return { store, rec }; +} + +const settle = () => new Promise((r) => setImmediate(r)); +async function sync(d) { + await d.rec.read(); + for (let i = 0; i < 5; i++) await settle(); +} + +const LANES = [ + { + name: "sections", + lane: SECTIONS_LANE, + edit: (tree, key, value) => + setRegs(tree, [ + [["s", key, "name"], value], + [["s", key, "order"], 0], + [["s", key, "live"], true], + ]), + remove: (tree, key) => setRegs(tree, [[["s", key, "live"], false]]), + view: (tree) => + Object.fromEntries( + projectSections(tree).sections.map((s) => [s.id, s.name]), + ), + legacy: (items) => ({ + version: 1, + sections: Object.entries(items).map(([id, name], order) => ({ + id, + name, + order, + })), + assignments: {}, + }), + big: (tree) => + setRegs( + tree, + Array.from({ length: 101 }, (_, i) => [ + ["s", `s${i}`, "live"], + true, + ]).flatMap((w, i) => [ + w, + [["s", `s${i}`, "name"], "n"], + [["s", `s${i}`, "order"], i], + ]), + ), + }, + { + name: "sort", + lane: SORT_LANE, + edit: (tree, key, value) => + setRegs(tree, [ + [ + ["g", `section:${key}`], + value === "B" || value === "Y" ? "recent" : "alpha", + ], + ]), + remove: (tree, key) => setRegs(tree, [[["g", `section:${key}`], null]]), + view: (tree) => + Object.fromEntries( + Object.entries(projectSort(tree).groups).map(([k, m]) => [ + k.slice(8), + m === "recent" ? "B" : "A", + ]), + ), + legacy: (items) => ({ + version: 1, + groups: Object.fromEntries( + Object.entries(items).map(([k, v]) => [ + `section:${k}`, + v === "B" ? "recent" : "alpha", + ]), + ), + }), + big: (tree) => + setRegs( + tree, + Array.from({ length: 105 }, (_, i) => [ + ["g", `section:${i}`], + "recent", + ]), + ), + }, +]; + +for (const L of LANES) { + test(`${L.name}: new scope with local data publishes its first copy`, async () => { + const d = device(L.lane); + d.store.transact((t) => L.edit(t, "k1", "A")); + await sync(d); + assert.equal(fx.published.length, 1); + const doc = JSON.parse(fx.published[0].content); + assert.equal(doc.version, 1); + assert.equal(doc.meta.v, 1); + await sync(d); + assert.equal(fx.published.length, 1, "equal digest: no republish"); + }); + + test(`${L.name}: absence after a seen head holds (never publishes over it)`, async () => { + const d = device(L.lane); + d.store.transact((t) => L.edit(t, "k1", "A")); + await sync(d); + fx.head = null; // relay transiently returns nothing + const d2 = device(L.lane); // restart: watermark > 0 + d2.store.transact((t) => L.edit(t, "k2", "B")); + await sync(d2); + assert.equal(fx.published.length, 1); + assert.equal(d2.rec.head.status, "unknown"); + }); + + test(`${L.name}: stale device merges instead of overwriting newer items`, async () => { + const a = device(L.lane); + a.store.transact((t) => L.edit(t, "k1", "A")); + await sync(a); + storage.clear(); // B: separate install with its own local cache + const b = device(L.lane); + b.store.transact((t) => L.edit(t, "k2", "B")); + await sync(b); + assert.deepEqual(L.view(JSON.parse(fx.head.content).meta), { + k1: "A", + k2: "B", + }); + await sync(a); + assert.deepEqual(L.view(a.store.get()), { k1: "A", k2: "B" }); + }); + + test(`${L.name}: a delete on one device survives a stale device's publish`, async () => { + const a = device(L.lane); + a.store.transact((t) => L.edit(L.edit(t, "k1", "A"), "k2", "B")); + await sync(a); + const snapshot = new Map(storage); + a.store.transact((t) => L.remove(t, "k1")); + await sync(a); + storage.clear(); + for (const [k, v] of snapshot) if (!k.includes("clock")) storage.set(k, v); + const stale = device(L.lane); // cache from before the delete + stale.store.transact((t) => L.edit(t, "k3", "A")); + await sync(stale); + assert.deepEqual(L.view(JSON.parse(fx.head.content).meta), { + k2: "B", + k3: "A", + }); + }); + + test(`${L.name}: old writer without meta adds items but cannot delete`, async () => { + const a = device(L.lane); + a.store.transact((t) => L.edit(t, "k1", "A")); + await sync(a); + fx.head = ev(JSON.stringify(L.legacy({ k9: "B" })), fx.head.created_at + 1); + await sync(a); + assert.deepEqual(L.view(a.store.get()), { k1: "A", k9: "B" }); + assert.equal( + JSON.parse(fx.head.content).meta.v, + 1, + "next upgraded publish restores meta", + ); + }); + + test(`${L.name}: meta-less local cache imports once at a stable stamp`, async () => { + storage.set( + L.lane.storageKey(PK, RELAY), + JSON.stringify(L.legacy({ k1: "A" })), + ); + const d = device(L.lane); + const first = canonical(d.store.get()); + assert.deepEqual(L.view(d.store.get()), { k1: "A" }); + assert.equal(canonical(device(L.lane).store.get()), first); + }); + + test(`${L.name}: publish exits (timeout, reject, duplicate) release the attempt`, async (t) => { + t.mock.timers.enable({ apis: ["Date"], now: 1e12 }); + for (const outcome of ["timeout", "reject", "duplicate"]) { + storage.clear(); + fx.head = null; + fx.publish = outcome; + const d = device(L.lane); + d.store.transact((t) => L.edit(t, "k1", "A")); + const before = fx.published.length; + await sync(d); + assert.equal(fx.published.length, before + 1, outcome); + fx.publish = "ok"; + if (outcome === "duplicate") fx.head = fx.published.at(-1); + t.mock.timers.tick(60_000); // past the failure backoff + await sync(d); + assert.equal( + fx.published.length, + before + (outcome === "duplicate" ? 1 : 2), + `${outcome} retry`, + ); + } + }); + + test(`${L.name}: preflight failure inside an attempt backs off, then recovers`, async (t) => { + t.mock.timers.enable({ apis: ["Date"], now: 1e12 }); + const d = device(L.lane); + d.store.transact((tr) => L.edit(tr, "k2", "B")); + fx.fetchFails = true; // the attempt's own preflight read fails + await d.rec.ingest(ev(JSON.stringify(L.legacy({ k1: "A" })), 100)); + for (let i = 0; i < 5; i++) await settle(); + assert.equal(fx.published.length, 0); + fx.fetchFails = false; + fx.head = ev(JSON.stringify(L.legacy({ k1: "A" })), 100); + await sync(d); + assert.equal(fx.published.length, 0, "held by the backoff"); + t.mock.timers.tick(5_000); + await sync(d); + assert.equal(fx.published.length, 1); + }); + + test(`${L.name}: a stale preflight decode cannot undo a newer absence`, async () => { + const h0 = ev(JSON.stringify(L.legacy({ k1: "A" })), 100); + const d = device(L.lane); + d.store.transact((t) => L.edit(t, "k2", "B")); + let release; + fx.holdDecrypt = new Promise((r) => (release = r)); + fx.holdAfter = 1; // the attempt's preflight decode of H0 waits + fx.head = h0; + void d.rec.ingest(h0); + for (let i = 0; i < 5; i++) await settle(); + fx.head = null; + await d.rec.read(); // recovery read during the acquired attempt + release(); + for (let i = 0; i < 5; i++) await settle(); + assert.equal(fx.published.length, 0); + assert.equal(d.rec.head.status, "unknown"); + fx.holdDecrypt = null; + fx.head = h0; + await sync(d); + assert.equal(fx.published.length, 1, "a later readable head re-enables"); + }); + + for (const fails of [false, true]) { + const what = fails ? "a failure to its full backoff" : "the send"; + test(`${L.name}: an edit during preflight holds ${what}`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 1e12 }); + const h0 = ev(JSON.stringify(L.legacy({ k1: "A" })), 100); + const store = new LaneStore(L.lane, PK, RELAY); + const rec = new LaneReconciler(L.lane, store, PK, RELAY); + store.transact((tr) => L.edit(tr, "k2", "B")); + let open; + fx.gate = new Promise((r) => (open = r)); + fx.head = h0; + void rec.ingest(h0); // attempt acquired; preflight fetch waits + for (let i = 0; i < 5; i++) await settle(); + store.transact((tr) => L.edit(tr, "k3", "A")); + rec.defer(); // edit deadline: +2 s + fx.fetchFails = fails; // the acquired preflight then really fails + fx.gate = null; + open(); + for (let i = 0; i < 5; i++) await settle(); + fx.fetchFails = false; + assert.equal(fx.published.length, 0); + t.mock.timers.tick(2_000); // real wake() re-arms; no manual read + for (let i = 0; i < 5; i++) await settle(); + assert.equal(fx.published.length, fails ? 0 : 1, "edit deadline"); + t.mock.timers.tick(3_000); + for (let i = 0; i < 5; i++) await settle(); + assert.equal(fx.published.length, 1, "after the 5 s failure backoff"); + rec.destroy(); + }); + } + + test(`${L.name}: a relay rejection with the cancel text during a hold backs off`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 1e12 }); + const store = new LaneStore(L.lane, PK, RELAY); + const rec = new LaneReconciler(L.lane, store, PK, RELAY); + store.transact((tr) => L.edit(tr, "k1", "A")); + fx.publish = "cancelText"; + fx.afterSend = () => rec.defer(); // an edit hold starts mid-send + await rec.read(); + for (let i = 0; i < 5; i++) await settle(); + assert.equal(fx.published.length, 1); + t.mock.timers.tick(2_000); + for (let i = 0; i < 5; i++) await settle(); + assert.equal(fx.published.length, 1, "not re-armed as a hold"); + t.mock.timers.tick(3_000); + for (let i = 0; i < 5; i++) await settle(); + assert.equal(fx.published.length, 2, "retried after the 5 s backoff"); + rec.destroy(); + }); + + test(`${L.name}: a hold cancel consumed after its deadline stays a hold`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 1e12 }); + const store = new LaneStore(L.lane, PK, RELAY); + const rec = new LaneReconciler(L.lane, store, PK, RELAY); + store.transact((tr) => L.edit(tr, "k1", "A")); + let consume; + fx.consume = new Promise((r) => (consume = r)); + fx.beforeSend = () => { + fx.beforeSend = null; + rec.defer(); + }; + await rec.read(); // the send is canceled by the hold + for (let i = 0; i < 5; i++) await settle(); + assert.equal(fx.beforeSend, null, "canceled at the send boundary"); + t.mock.timers.tick(2_000); // the hold expires before the cancel is consumed + fx.consume = null; + consume(); + for (let i = 0; i < 5; i++) await settle(); + t.mock.timers.tick(1); // the kept, expired deadline re-arms at once + for (let i = 0; i < 5; i++) await settle(); + assert.equal(fx.published.length, 1, "no failure backoff"); + rec.destroy(); + }); + + test(`${L.name}: unreadable head holds quietly; content never published over it`, async () => { + const d = device(L.lane); + d.store.transact((t) => L.edit(t, "k1", "A")); + fx.head = ev("not json", 10); + await sync(d); + assert.equal(d.rec.head.status, "unreadable"); + assert.equal(fx.published.length, 0); + }); + + test(`${L.name}: stale decode merges content but does not settle status`, async () => { + const d = device(L.lane); + const older = ev(JSON.stringify(L.legacy({ k1: "A" })), 10); + const newer = ev("garbage", 11); + fx.badDecrypt.add("garbage"); + const p = d.rec.ingest(older); + await d.rec.ingest(newer); // newer head observed before older decode lands + await p; + assert.equal(d.rec.head.id, newer.id); + assert.equal(d.rec.head.status, "unreadable"); + assert.deepEqual(L.view(d.store.get()), { k1: "A" }); + }); + + test(`${L.name}: over limit stays local and is not published`, async () => { + const d = device(L.lane); + d.store.transact(L.big); + await sync(d); + assert.equal(fx.published.length, 0); + assert.ok( + storage.get(L.lane.storageKey(PK, RELAY)).length > 1000, + "kept durable locally", + ); + }); + + test(`${L.name}: destroyed reconciler never publishes`, async () => { + const d = device(L.lane); + d.store.transact((t) => L.edit(t, "k1", "A")); + d.rec.destroy(); + await sync(d); + assert.equal(fx.published.length, 0); + }); +} + +test("sections: projection is live-only, dense integer order, live assignments", () => { + const A = "a".repeat(16); + const tree = { + s: { + x: { name: [1, A, "X"], order: [1, A, 7], live: [1, A, true] }, + y: { name: [1, A, "Y"], order: [1, A, 3], live: [1, A, true] }, + z: { name: [1, A, "Z"], order: [1, A, 0], live: [1, A, false] }, + }, + a: { c1: [1, A, "x"], c2: [1, A, "z"], c3: [1, A, null] }, + }; + assert.deepEqual(projectSections(tree), { + sections: [ + { id: "y", name: "Y", order: 0 }, + { id: "x", name: "X", order: 1 }, + ], + assignments: { c1: "x" }, + }); +}); + +test("sections: rename concurrent with delete stays deleted", async () => { + const d = device(SECTIONS_LANE); + d.store.transact((t) => LANES[0].edit(t, "k1", "A")); + const base = d.store.get(); + const renamed = setRegs(base, [[["s", "k1", "name"], "B"]]); + const deleted = setRegs(base, [[["s", "k1", "live"], false]]); + d.store.merge(renamed); + d.store.merge(deleted); + assert.deepEqual(projectSections(d.store.get()).sections, []); +}); + +// ─── pass-1 regressions ──────────────────────────────────────────────────── + +for (const L of LANES) { + test(`${L.name}: absence on a running reconciler holds until a head returns`, async () => { + const d = device(L.lane); + d.store.transact((t) => L.edit(t, "k1", "A")); + await sync(d); + const h1 = fx.head; + fx.head = null; // transient empty read after H0 was decoded + d.store.transact((t) => L.edit(t, "k2", "B")); + await sync(d); + assert.equal(fx.published.length, 1); + assert.equal(d.rec.head.status, "unknown"); + fx.head = h1; + await sync(d); + assert.equal(fx.published.length, 2, "readable head re-enables publish"); + }); + + test(`${L.name}: empty verification after an ACK does not republish`, async () => { + fx.publish = "lost"; // ACKed, but reads keep returning nothing + const d = device(L.lane); + d.store.transact((t) => L.edit(t, "k1", "A")); + await sync(d); + await sync(d); + await sync(d); + assert.equal(fx.published.length, 1); + }); + + test(`${L.name}: a stale absent read cannot demote a newer observation`, async () => { + const d = device(L.lane); + d.store.transact((t) => L.edit(t, "k1", "A")); + let open; + fx.gate = new Promise((r) => (open = r)); + const pending = d.rec.read(); // absent, still in flight + fx.gate = null; + fx.head = ev(JSON.stringify(L.legacy({ k1: "A" })), 50); + await d.rec.ingest(fx.head); + open(); + await pending; + assert.equal(d.rec.head.status, "decoded"); + await sync(d); // drain the attempt this head enabled + }); + + test(`${L.name}: an imported empty container settles without publishing`, async () => { + storage.set(L.lane.storageKey(PK, RELAY), JSON.stringify(L.legacy({}))); + const d = device(L.lane); + await sync(d); + assert.equal(fx.published.length, 0); + }); + + test(`${L.name}: backoff holds recovery, live and reconnect attempts`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 1e12 }); + const store = new LaneStore(L.lane, PK, RELAY); + const rec = new LaneReconciler(L.lane, store, PK, RELAY); + fx.publish = "reject"; + store.transact((tr) => L.edit(tr, "k1", "A")); + await rec.read(); + for (let i = 0; i < 5; i++) await settle(); + assert.equal(fx.published.length, 1); + fx.publish = "ok"; + await rec.read(); // recovery tick at t+0 + rec.wake(); // reconnect must not shorten the 5 s backoff + t.mock.timers.tick(4_000); + for (let i = 0; i < 5; i++) await settle(); + assert.equal(fx.published.length, 1, "held inside backoff"); + t.mock.timers.tick(1_000); + for (let i = 0; i < 5; i++) await settle(); + assert.equal(fx.published.length, 2, "published after the deadline"); + rec.destroy(); + }); + + test(`${L.name}: a change during the publish wait cancels the send`, async () => { + const d = device(L.lane); + d.store.transact((t) => L.edit(t, "k1", "A")); + fx.beforeSend = () => d.store.transact((t) => L.edit(t, "k2", "B")); + await sync(d); + assert.equal(fx.published.length, 0); + fx.beforeSend = null; + d.rec.destroy(); + }); + + test(`${L.name}: legacy pubkey key migrates once and stays relay-scoped`, () => { + const legacyKey = L.lane.legacyStorageKey?.(PK); + if (!legacyKey) return; + storage.set(legacyKey, JSON.stringify(L.legacy({ k1: "A" }))); + fx.storageFails = true; + const failed = new LaneStore(L.lane, PK, RELAY); + assert.deepEqual(L.view(failed.get()), { k1: "A" }); + assert.ok(storage.has(legacyKey), "kept until the scoped write lands"); + fx.storageFails = false; + failed.persist(); // same store, e.g. the next recovery tick + assert.ok(!storage.has(legacyKey)); + const other = new LaneStore(L.lane, PK, "wss://other.test"); + assert.deepEqual(L.view(other.get()), {}, "second relay starts clean"); + storage.set(legacyKey, JSON.stringify(L.legacy({ k9: "B" }))); + assert.deepEqual(L.view(new LaneStore(L.lane, PK, RELAY).get()), { + k1: "A", + }); + }); + + test(`${L.name}: two tabs converge and go quiet`, () => { + const handlers = []; + window.addEventListener = (_type, h) => handlers.push(h); + const tabs = [0, 1].map(() => new LaneStore(L.lane, PK, RELAY)); + for (const tab of tabs) tab.attachCrossTab(); + window.addEventListener = () => {}; + const key = L.lane.storageKey(PK, RELAY); + const deliver = (to) => + handlers[to]({ key, newValue: storage.get(key) ?? null }); + tabs[0].transact((t) => L.edit(t, "k1", "A")); + const lost = storage.get(key); + tabs[1].transact((t) => L.edit(t, "k2", "B")); // overwrites tab 0's write + handlers[1]({ key, newValue: lost }); + deliver(0); + const writes = fx.writes; + let notified = 0; + for (const tab of tabs) tab.subscribe(() => notified++); + deliver(0); + deliver(1); + assert.equal(fx.writes, writes, "no further writes"); + assert.equal(notified, 0, "no further notifications"); + assert.deepEqual(L.view(tabs[0].get()), { k1: "A", k2: "B" }); + assert.equal(canonical(tabs[0].get()), canonical(tabs[1].get())); + }); + + test(`${L.name}: a byte-identical clone does not write or notify`, () => { + const d = device(L.lane); + d.store.transact((t) => L.edit(t, "k1", "A")); + let notified = 0; + d.store.subscribe(() => notified++); + d.store.transact((t) => ({ ...t })); + assert.equal(notified, 0); + }); +} + +test("publisher: isCurrent gates the first send and the reconnect retry", async () => { + const sends = []; + let current = true; + const session = { + generation: () => 1, + ownership: () => 1, + pendingEvents: new Map(), + send: async (payload) => { + sends.push(payload); + throw new Error("socket closed"); + }, + reconnect: async () => { + current = false; // lane changed while reconnecting + return 1; + }, + normalizeError: (e) => e, + recoverSocketFailure: (e) => e, + }; + Object.assign(globalThis.window, { setTimeout, clearTimeout }); + const event = { id: "e1" }; + await assert.rejects( + publishSessionEvent(session, event, "t", "s", () => current), + PublishCanceledError, + ); + assert.equal(sends.length, 1, "retry never sent"); + assert.equal(session.pendingEvents.size, 0); + await assert.rejects( + publishSessionEvent(session, event, "t", "s", () => false), + PublishCanceledError, + ); + assert.equal(sends.length, 1, "first send never sent"); +}); diff --git a/desktop/src/features/sidebar/lib/sidebarLwwMap.test.mjs b/desktop/src/features/sidebar/lib/sidebarLwwMap.test.mjs new file mode 100644 index 0000000000..70116ebacf --- /dev/null +++ b/desktop/src/features/sidebar/lib/sidebarLwwMap.test.mjs @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + canonical, + compareRegs, + LEGACY_DEV, + mergeTrees, + setRegs, + validTree, +} from "./sidebarLwwMap.ts"; + +const storage = new Map(); +globalThis.window ??= {}; +globalThis.window.localStorage = { + getItem: (k) => storage.get(k) ?? null, + setItem: (k, v) => storage.set(k, v), + removeItem: (k) => storage.delete(k), +}; + +const A = "a".repeat(16); +const B = "b".repeat(16); + +test("compareRegs: version, then device, then tombstone, then value bytes", () => { + assert.ok(compareRegs([2, A, "x"], [1, B, "y"]) > 0); + assert.ok(compareRegs([1, B, "x"], [1, A, "y"]) > 0); + assert.ok(compareRegs([1, A, null], [1, A, "x"]) > 0); + assert.ok(compareRegs([1, A, false], [1, A, true]) > 0); + assert.ok(compareRegs([1, A, "b"], [1, A, "a"]) > 0); + assert.equal(compareRegs([1, A, "a"], [1, A, "a"]), 0); +}); + +test("mergeTrees: commutative, associative, idempotent", () => { + const x = { s: { k: { name: [1, A, "x"] } }, a: { c1: [5, A, "k"] } }; + const y = { s: { k: { name: [2, B, "y"], live: [1, B, true] } } }; + const z = { a: { c1: [5, B, null], c2: [3, A, "k"] } }; + const xy = mergeTrees(x, y); + assert.equal(canonical(xy), canonical(mergeTrees(y, x))); + assert.equal( + canonical(mergeTrees(mergeTrees(x, y), z)), + canonical(mergeTrees(x, mergeTrees(y, z))), + ); + assert.equal(mergeTrees(xy, xy), xy); + assert.deepEqual(xy.s.k.name, [2, B, "y"]); +}); + +test("mergeTrees: returns the same reference when nothing changes", () => { + const a = { g: { dms: [9, A, "recent"] } }; + assert.equal(mergeTrees(a, { g: { dms: [1, B, "alpha"] } }), a); +}); + +test("mergeTrees onlyMissing: fills absent leaves and never overrides", () => { + const local = { s: { k: { name: [1, A, "mine"] } } }; + const legacy = { + s: { + k: { name: [9e12, LEGACY_DEV, "old"] }, + n: { name: [9e12, LEGACY_DEV, "new"] }, + }, + }; + const out = mergeTrees(local, legacy, true); + assert.equal(out.s.k.name[2], "mine"); + assert.equal(out.s.n.name[2], "new"); +}); + +test("setRegs: identical edit mints no version and returns the same tree", () => { + const t = setRegs({}, [[["g", "dms"], "recent"]]); + assert.equal(setRegs(t, [[["g", "dms"], "recent"]]), t); +}); + +test("setRegs: new version exceeds every version seen and the install's last", () => { + const future = Date.now() + 1e9; + const t = setRegs({ g: { dms: [future, B, "alpha"] } }, [ + [["g", "dms"], "recent"], + ]); + assert.equal(t.g.dms[0], future + 1); + const t2 = setRegs({}, [[["g", "x"], "alpha"]]); + assert.ok(t2.g.x[0] > future, "lastIssued persists across documents"); + assert.equal(t2.g.x[1], t.g.dms[1], "device id persists per install"); + assert.match(t2.g.x[1], /^[0-9a-f]{16}$/); +}); + +test("setRegs all: writes every leaf, including unchanged ones", () => { + const t = { s: { a: { order: [1, A, 0] }, b: { order: [1, A, 1] } } }; + const out = setRegs( + t, + [ + [["s", "a", "order"], 0], + [["s", "b", "order"], 0], + ], + true, + ); + assert.ok(out.s.a.order[0] > 1); + assert.equal(out.s.a.order[0], out.s.b.order[0]); +}); + +test("validTree: skips invalid records, keeps valid siblings", () => { + const shape = { + s: { + "*": { name: (v) => typeof v === "string", order: Number.isSafeInteger }, + }, + }; + const out = validTree( + { + s: { + ok: { name: [1, A, "n"], order: [1, A, 2] }, + badName: { name: [1, A, 7], order: [1, A, 0] }, + badDev: { name: [1, "zz", "n"] }, + badV: { name: [1.5, A, "n"] }, + __proto__: { name: [1, A, "p"] }, + }, + extra: { x: [1, A, 1] }, + }, + shape, + ); + assert.deepEqual(out, { + s: { + ok: { name: [1, A, "n"], order: [1, A, 2] }, + badName: { order: [1, A, 0] }, + badDev: {}, + badV: {}, + }, + }); +}); + +test("canonical: key order independent and stable", () => { + assert.equal( + canonical({ b: 1, a: { d: [1], c: null } }), + canonical({ a: { c: null, d: [1] }, b: 1 }), + ); + assert.equal(canonical({ a: undefined, b: 1 }), '{"b":1}'); +}); + +test("inherited names are ordinary keys in either arrival order", () => { + const A = "a".repeat(16); + const one = { g: { constructor: [2, A, "recent"] } }; + const two = { g: { toString: [3, A, "alpha"] } }; + const ab = mergeTrees(mergeTrees({}, one), two); + const ba = mergeTrees(mergeTrees({}, two), one); + assert.equal(canonical(ab), canonical(ba)); + assert.deepEqual(Object.keys(ab.g).sort(), ["constructor", "toString"]); + const set = setRegs({ g: {} }, [[["g", "constructor"], "alpha"]]); + assert.equal(set.g.constructor[2], "alpha"); + const valid = validTree(JSON.parse(canonical(ab)), { + g: { "*": () => true }, + }); + assert.equal(canonical(valid), canonical(ab)); +}); diff --git a/desktop/src/features/sidebar/lib/sidebarLwwMap.ts b/desktop/src/features/sidebar/lib/sidebarLwwMap.ts new file mode 100644 index 0000000000..bb9a8771ac --- /dev/null +++ b/desktop/src/features/sidebar/lib/sidebarLwwMap.ts @@ -0,0 +1,203 @@ +/** + * Per-field last-writer-wins registers for synced sidebar preferences. + * + * A document is a tree whose leaves are registers `[v, dev, value]`: `v` is a + * millisecond version, `dev` a random per-install id. Merging keeps, for every + * leaf, the register that sorts highest, so any two devices that have seen the + * same registers hold byte-identical documents regardless of arrival order. + */ + +export type Reg = [v: number, dev: string, value: T]; +export type Tree = { [key: string]: Tree | Reg | undefined }; + +/** Device id stamped on registers imported from meta-less (legacy) data. */ +export const LEGACY_DEV = "0".repeat(16); + +const CLOCK_KEY = "buzz-sidebar-clock.v1"; +const DEV_RE = /^[0-9a-f]{16}$/; +const encoder = new TextEncoder(); + +function compareBytes(left: string, right: string): number { + const a = encoder.encode(left); + const b = encoder.encode(right); + for (let i = 0; i < Math.min(a.length, b.length); i++) { + if (a[i] !== b[i]) return (a[i] as number) - (b[i] as number); + } + return a.length - b.length; +} + +const isTombstone = (value: unknown) => value === null || value === false; + +/** Orders by `v`, then `dev` bytewise, then tombstone over value, then value JSON bytewise. */ +export function compareRegs(a: Reg, b: Reg): number { + if (a[0] !== b[0]) return a[0] - b[0]; + if (a[1] !== b[1]) return compareBytes(a[1], b[1]); + const tombA = isTombstone(a[2]); + if (tombA !== isTombstone(b[2])) return tombA ? 1 : -1; + return compareBytes(JSON.stringify(a[2]), JSON.stringify(b[2])); +} + +export const isReg = (node: unknown): node is Reg => Array.isArray(node); + +/** Own-property read: dynamic keys such as `constructor` never hit the prototype. */ +export const own = (node: Tree, key: string): Tree | Reg | undefined => + Object.hasOwn(node, key) ? node[key] : undefined; + +/** True when no register (tombstones included) exists anywhere in `node`. */ +export const isEmptyTree = (node: Tree): boolean => + Object.values(node).every( + (child) => child === undefined || (!isReg(child) && isEmptyTree(child)), + ); + +/** A register imported from a meta-less local cache (stamp 1); any remote beats it. */ +const isCachePlaceholder = (reg: Reg) => reg[0] === 1 && reg[1] === LEGACY_DEV; + +/** + * Merges `b` into `a`. With `onlyMissing`, `b` only fills leaves `a` lacks or + * holds as a cache placeholder (legacy import: omission never means deletion, + * and authored registers win). Returns `a` itself when nothing changes. + */ +export function mergeTrees(a: T, b: T, onlyMissing = false): T { + let out: Tree | null = null; + for (const [key, bv] of Object.entries(b)) { + const av = own(a, key); + let next = av; + if (av === undefined) next = bv; + else if (bv === undefined) continue; + else if (isReg(av) && isReg(bv)) { + if ((!onlyMissing || isCachePlaceholder(av)) && compareRegs(bv, av) > 0) + next = bv; + } else if (!isReg(av) && !isReg(bv)) { + next = mergeTrees(av, bv, onlyMissing); + } + if (next !== av) { + out ??= { ...a }; + out[key] = next; + } + } + return (out ?? a) as T; +} + +function maxVersion(node: Tree): number { + let max = 0; + for (const child of Object.values(node)) { + if (child === undefined) continue; + max = Math.max(max, isReg(child) ? child[0] : maxVersion(child)); + } + return max; +} + +function randomDev(): string { + const bytes = crypto.getRandomValues(new Uint8Array(8)); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** `v = max(now, lastIssued + 1, maxSeen + 1)`; `dev` and `lastIssued` persist per install. */ +function nextStamp(maxSeen: number): [number, string] { + let dev = randomDev(); + let last = 0; + try { + const saved = JSON.parse(window.localStorage.getItem(CLOCK_KEY) ?? "null"); + if (DEV_RE.test(saved?.dev) && Number.isSafeInteger(saved?.last)) { + dev = saved.dev; + last = saved.last; + } + } catch { + // Fall back to a fresh clock; ordering still holds via `maxSeen`. + } + const v = Math.max(Date.now(), last + 1, maxSeen + 1); + try { + window.localStorage.setItem(CLOCK_KEY, JSON.stringify({ dev, last: v })); + } catch { + // Non-fatal: the next stamp still exceeds every version in the document. + } + return [v, dev]; +} + +function getAt(tree: Tree, path: string[]): Tree | Reg | undefined { + let node: Tree | Reg | undefined = tree; + for (const key of path) { + if (node === undefined || isReg(node)) return undefined; + node = own(node, key); + } + return node; +} + +function setAt(tree: Tree, [key, ...rest]: string[], reg: Reg): Tree { + const child = own(tree, key as string); + const next = + rest.length === 0 + ? reg + : setAt(child && !isReg(child) ? child : {}, rest, reg); + return { ...tree, [key as string]: next }; +} + +/** + * Writes each `[path, value]` as a fresh register sharing one stamp. An edit + * where every target already holds its value mints no version and returns + * `tree` itself. Otherwise only changed leaves are written, or with `all` + * every leaf (a whole-list operation such as a reorder). + */ +export function setRegs( + tree: T, + writes: Array<[path: string[], value: unknown]>, + all = false, +): T { + const changed = writes.filter(([path, value]) => { + const current = getAt(tree, path); + return !( + isReg(current) && JSON.stringify(current[2]) === JSON.stringify(value) + ); + }); + if (changed.length === 0) return tree; + const [v, dev] = nextStamp(maxVersion(tree)); + let out: Tree = tree; + for (const [path, value] of all ? writes : changed) + out = setAt(out, path, [v, dev, value]); + return out as T; +} + +/** A leaf validator, or a node shape: fixed fields, or `{"*": child}` for maps. */ +export type Shape = ((value: unknown) => boolean) | { [key: string]: Shape }; + +const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * Keeps only well-formed nodes of `shape`. An invalid register or record is + * dropped while its valid siblings survive. + */ +export function validTree(node: unknown, shape: Shape): Tree | Reg | undefined { + if (typeof shape === "function") { + return isReg(node) && + node.length === 3 && + Number.isSafeInteger(node[0]) && + node[0] >= 0 && + typeof node[1] === "string" && + DEV_RE.test(node[1]) && + shape(node[2]) + ? node + : undefined; + } + if (!isObject(node)) return undefined; + const out: Tree = {}; + for (const [key, child] of Object.entries(node)) { + if (key === "__proto__") continue; + const sub = Object.hasOwn(shape, key) ? shape[key] : shape["*"]; + const valid = sub === undefined ? undefined : validTree(child, sub); + if (valid !== undefined) out[key] = valid; + } + return out; +} + +/** Stable key-sorted JSON; the digest and no-op comparison for documents. */ +export function canonical(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (isObject(value)) { + const keys = Object.keys(value) + .filter((key) => value[key] !== undefined) + .sort(compareBytes); + return `{${keys.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`; + } + return JSON.stringify(value); +} diff --git a/desktop/src/features/sidebar/lib/useChannelMutes.ts b/desktop/src/features/sidebar/lib/useChannelMutes.ts index 20b5745325..60b48d8242 100644 --- a/desktop/src/features/sidebar/lib/useChannelMutes.ts +++ b/desktop/src/features/sidebar/lib/useChannelMutes.ts @@ -14,6 +14,7 @@ import { } from "./channelMutesStorage"; import { ChannelMuteSyncManager } from "./channelMutesSync"; import type { RemoteMutes } from "./channelMutesSync"; +import { useStaleReaderRecovery } from "./useStaleReaderRecovery"; export function useChannelMutes( pubkey: string | undefined, @@ -33,6 +34,9 @@ export function useChannelMutes( const managerRef = React.useRef(null); const lastAppliedRemoteTs = React.useRef(0); const lastAppliedEventId = React.useRef(""); + // Local-mutation revision: incremented on every user edit so an in-flight + // retry fetch that started before the edit is discarded at apply time. + const localRevision = React.useRef(0); React.useEffect(() => { if (!pubkey || !relayUrl) { @@ -75,14 +79,16 @@ export function useChannelMutes( if (remote.createdAt < lastAppliedRemoteTs.current) return prev; if ( remote.createdAt === lastAppliedRemoteTs.current && - remote.eventId <= lastAppliedEventId.current + remote.eventId >= lastAppliedEventId.current ) return prev; - lastAppliedRemoteTs.current = remote.createdAt; - lastAppliedEventId.current = remote.eventId; managerRef.current?.cancelPendingMutePublish(); const merged = mergeStores(prev, remote.store); if (!writeChannelMutesStore(pubkey, merged)) return prev; + // Advance the applied head only after the cache write succeeds so a + // failed write leaves the same head retryable on the next tick. + lastAppliedRemoteTs.current = remote.createdAt; + lastAppliedEventId.current = remote.eventId; return merged; }; }, @@ -150,6 +156,31 @@ export function useChannelMutes( }; }, [pubkey, relayUrl, applyRemote]); + // Retry effect: see useStaleReaderRecovery for full behavior contract. + const retryFetch = React.useCallback( + () => managerRef.current?.fetchRemoteMutes(), + [], + ); + const retryHasPending = React.useCallback( + () => managerRef.current?.getPendingMuteStore() != null, + [], + ); + const retryGetRevision = React.useCallback(() => localRevision.current, []); + const retryMakeUpdater = React.useCallback( + (data: RemoteMutes) => applyRemote(data), + [applyRemote], + ); + useStaleReaderRecovery({ + enabled: !!pubkey && !!relayUrl, + fetch: retryFetch, + hasPending: retryHasPending, + getRevision: retryGetRevision, + makeUpdater: retryMakeUpdater, + setStore, + pubkey, + relayUrl, + }); + // biome-ignore lint/correctness/useExhaustiveDependencies: store.channels is the relevant dep — the outer store identity can change without channels changing (e.g., on reconnect writes) const mutedChannelIds = React.useMemo( () => mutedChannelIdsFromStore(store), @@ -172,6 +203,7 @@ export function useChannelMutes( channelId, ); if (!writeChannelMutesStore(pubkey, next)) return prev; + localRevision.current += 1; managerRef.current?.publishMutes(next); return next; }); diff --git a/desktop/src/features/sidebar/lib/useChannelSections.test.mjs b/desktop/src/features/sidebar/lib/useChannelSections.test.mjs deleted file mode 100644 index 401b59d9c1..0000000000 --- a/desktop/src/features/sidebar/lib/useChannelSections.test.mjs +++ /dev/null @@ -1,78 +0,0 @@ -import assert from "node:assert/strict"; -import { after, before, test } from "node:test"; - -import { JSDOM } from "jsdom"; - -const dom = new JSDOM("", { - url: "http://localhost", -}); - -before(() => { - Object.assign(globalThis, { - document: dom.window.document, - HTMLElement: dom.window.HTMLElement, - IS_REACT_ACT_ENVIRONMENT: true, - window: dom.window, - }); -}); - -after(() => dom.window.close()); - -test("assignChannel refreshes an existing assignment before the next eviction", async () => { - const { act, cleanup, renderHook } = await import("@testing-library/react"); - const { relayClient } = await import("@/shared/api/relayClient"); - const { MAX_CHANNEL_SECTION_ASSIGNMENTS, storageKey } = await import( - "./channelSectionsStorage.ts" - ); - const { useChannelSections } = await import("./useChannelSections.ts"); - - const originalFetchEvents = relayClient.fetchEvents; - const originalSubscribeLive = relayClient.subscribeLive; - const originalSubscribeToReconnects = relayClient.subscribeToReconnects; - relayClient.fetchEvents = async () => []; - relayClient.subscribeLive = async () => async () => {}; - relayClient.subscribeToReconnects = () => () => {}; - - const pubkey = "pk-at-capacity"; - const relayUrl = "wss://relay.example"; - const assignments = Object.fromEntries( - Array.from({ length: MAX_CHANNEL_SECTION_ASSIGNMENTS }, (_, index) => [ - `chan-${String(index).padStart(4, "0")}`, - "section-1", - ]), - ); - window.localStorage.setItem( - storageKey(pubkey, relayUrl), - JSON.stringify({ - version: 1, - sections: [ - { id: "section-1", name: "One", order: 0 }, - { id: "section-2", name: "Two", order: 1 }, - ], - assignments, - }), - ); - - try { - const { result, unmount } = renderHook(() => - useChannelSections(pubkey, relayUrl), - ); - - act(() => result.current.assignChannel("chan-0000", "section-2")); - act(() => result.current.assignChannel("chan-new", "section-1")); - - assert.equal(result.current.assignments["chan-0000"], "section-2"); - assert.equal(result.current.assignments["chan-new"], "section-1"); - assert.equal(result.current.assignments["chan-0001"], undefined); - assert.equal( - Object.keys(result.current.assignments).length, - MAX_CHANNEL_SECTION_ASSIGNMENTS, - ); - unmount(); - } finally { - cleanup(); - relayClient.fetchEvents = originalFetchEvents; - relayClient.subscribeLive = originalSubscribeLive; - relayClient.subscribeToReconnects = originalSubscribeToReconnects; - } -}); diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts index 5a544e82bc..9278b6dc43 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.ts +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -1,24 +1,38 @@ import * as React from "react"; -import { relayClient } from "@/shared/api/relayClient"; import { - boundChannelSectionsStore, - DEFAULT_STORE, - readChannelSectionsStore, - storageKey, - writeChannelSectionsStore, -} from "./channelSectionsStorage"; -import { ChannelSectionSyncManager } from "./channelSectionsSync"; -import type { RemoteSections } from "./channelSectionsSync"; -import { swapSectionOrder } from "./channelSectionsHelpers"; + maxLiveOrder, + projectSections, + SECTIONS_LANE, +} from "./channelSectionsSync"; +import { useLaneSync } from "./sidebarLaneReconciler"; +import { own, setRegs, type Tree } from "./sidebarLwwMap"; export type { ChannelSection } from "./channelSectionsStorage"; -import type { - ChannelSection, - ChannelSectionStore, -} from "./channelSectionsStorage"; +import type { ChannelSection } from "./channelSectionsStorage"; +/** + * One whole-list order operation against the current live list: dead IDs are + * dropped, live IDs missing from `requested` keep their relative order at the + * end, and every live section gets a new `order` register. + */ +function writeOrder(tree: Tree, requested: string[]) { + const live = projectSections(tree).sections.map((s) => s.id); + const wanted = new Set(requested.filter((id) => live.includes(id))); + const ids = [...wanted, ...live.filter((id) => !wanted.has(id))]; + return setRegs( + tree, + ids.map((id, index) => [["s", id, "order"], index]), + true, + ); +} + +/** + * Channel sections for one pubkey + relay scope, merged per field across + * devices (see `sidebarLaneReconciler`). Deletes are `live = false` + * tombstones; unassign is an explicit `null` register. + */ export function useChannelSections( pubkey: string | undefined, relayUrl?: string, @@ -34,313 +48,93 @@ export function useChannelSections( assignChannel: (channelId: string, sectionId: string) => void; unassignChannel: (channelId: string) => void; } { - const [store, setStore] = React.useState(() => { - if (!pubkey) { - return DEFAULT_STORE; - } - return readChannelSectionsStore(pubkey, relayUrl); - }); - - const managerRef = React.useRef(null); - const lastAppliedRemoteTs = React.useRef(0); - const lastAppliedEventId = React.useRef(""); - - React.useEffect(() => { - if (!pubkey || !relayUrl) { - setStore(DEFAULT_STORE); - lastAppliedRemoteTs.current = 0; - lastAppliedEventId.current = ""; - return; - } - setStore(readChannelSectionsStore(pubkey, relayUrl)); - lastAppliedRemoteTs.current = 0; - lastAppliedEventId.current = ""; - managerRef.current = new ChannelSectionSyncManager(pubkey, relayUrl); - return () => { - managerRef.current?.destroy(); - managerRef.current = null; - }; - }, [pubkey, relayUrl]); - - React.useEffect(() => { - if (!pubkey) { - return; - } - const key = storageKey(pubkey, relayUrl); - const handler = (e: StorageEvent) => { - if (e.key !== key) { - return; - } - setStore(readChannelSectionsStore(pubkey, relayUrl)); - }; - window.addEventListener("storage", handler); - return () => { - window.removeEventListener("storage", handler); - }; - }, [pubkey, relayUrl]); - - const applyRemote = React.useCallback( - ( - remote: RemoteSections, - ): ((prev: ChannelSectionStore) => ChannelSectionStore) => { - return (prev) => { - if (!pubkey) return prev; - if (remote.createdAt < lastAppliedRemoteTs.current) return prev; - if ( - remote.createdAt === lastAppliedRemoteTs.current && - remote.eventId <= lastAppliedEventId.current - ) - return prev; - lastAppliedRemoteTs.current = remote.createdAt; - lastAppliedEventId.current = remote.eventId; - managerRef.current?.cancelPendingPublish(); - if (!writeChannelSectionsStore(pubkey, remote.store, relayUrl)) - return prev; - return remote.store; - }; - }, - [pubkey, relayUrl], - ); - - React.useEffect(() => { - if (!pubkey || !relayUrl) return; - let cancelled = false; - const local = readChannelSectionsStore(pubkey, relayUrl); - void managerRef.current?.bootstrap(local).then((result) => { - if (cancelled) return; - if (result.action === "apply-remote") { - setStore(applyRemote(result.data)); - } - // "hold": seed already performed by bootstrap (if first-sync), or - // blocked (failed fetch / prior watermark). Hook does nothing. - }); - return () => { - cancelled = true; - }; - }, [pubkey, relayUrl, applyRemote]); - - React.useEffect(() => { - if (!pubkey) return; - let unsub: (() => Promise) | null = null; - let cancelled = false; - void managerRef.current - ?.subscribeToSections((remote) => { - if (cancelled) return; - setStore(applyRemote(remote)); - }) - .then((dispose) => { - if (cancelled) { - void dispose(); - } else { - unsub = dispose; - } - }); - return () => { - cancelled = true; - if (unsub) void unsub(); - }; - }, [pubkey, applyRemote]); - - React.useEffect(() => { - if (!pubkey) return; - let cancelled = false; - const unsub = relayClient.subscribeToReconnects(() => { - void managerRef.current?.fetchRemoteSections().then((result) => { - if (cancelled) return; - if (result.status === "found") { - setStore(applyRemote(result.data)); - } - const pending = managerRef.current?.getPendingStore(); - if (pending) { - managerRef.current?.publishSections(pending); - } - }); - }); - return () => { - cancelled = true; - unsub(); - }; - }, [pubkey, applyRemote]); - - const sections = React.useMemo( - () => store.sections.slice().sort((a, b) => a.order - b.order), - [store.sections], + const { tree, edit } = useLaneSync(SECTIONS_LANE, pubkey, relayUrl); + const { sections, assignments } = React.useMemo( + () => projectSections(tree), + [tree], ); const createSection = React.useCallback( (name: string, icon?: string): ChannelSection | null => { - if (!pubkey) return null; - const prev = readChannelSectionsStore(pubkey, relayUrl); - const maxOrder = - prev.sections.length > 0 - ? Math.max(...prev.sections.map((s) => s.order)) - : -1; - const section: ChannelSection = { - id: crypto.randomUUID(), - name, - ...(icon ? { icon } : {}), - order: maxOrder + 1, - }; - setStore((current) => { - const next = boundChannelSectionsStore({ - ...current, - sections: [...current.sections, section], - }); - if (!writeChannelSectionsStore(pubkey, next, relayUrl)) return current; - managerRef.current?.publishSections(next); - return next; + if (!pubkey || !relayUrl) return null; + const id = crypto.randomUUID(); + let order = 0; + edit((prev) => { + order = projectSections(prev).sections.length; + return setRegs(prev, [ + [["s", id, "name"], name], + [["s", id, "icon"], icon || null], + [["s", id, "order"], maxLiveOrder(prev) + 1], + [["s", id, "live"], true], + ]); }); - return section; + return { id, name, ...(icon ? { icon } : {}), order }; }, - [pubkey, relayUrl], + [pubkey, relayUrl, edit], ); const renameSection = React.useCallback( - (sectionId: string, newName: string, icon?: string) => { - if (!pubkey) { - return; - } - setStore((prev) => { - const next: ChannelSectionStore = { - ...prev, - sections: prev.sections.map((s) => - s.id === sectionId - ? { - id: s.id, - name: newName, - ...(icon ? { icon } : {}), - order: s.order, - } - : s, - ), - }; - if (!writeChannelSectionsStore(pubkey, next, relayUrl)) { - return prev; - } - managerRef.current?.publishSections(next); - return next; - }); - }, - [pubkey, relayUrl], + (sectionId: string, newName: string, icon?: string) => + edit((prev) => + setRegs(prev, [ + [["s", sectionId, "name"], newName], + [["s", sectionId, "icon"], icon || null], + ]), + ), + [edit], ); const deleteSection = React.useCallback( - (sectionId: string) => { - if (!pubkey) { - return; - } - setStore((prev) => { - const assignments = { ...prev.assignments }; - for (const channelId of Object.keys(assignments)) { - if (assignments[channelId] === sectionId) { - delete assignments[channelId]; - } - } - const next: ChannelSectionStore = { - ...prev, - sections: prev.sections.filter((s) => s.id !== sectionId), - assignments, - }; - if (!writeChannelSectionsStore(pubkey, next, relayUrl)) { - return prev; - } - managerRef.current?.publishSections(next); - return next; - }); - }, - [pubkey, relayUrl], - ); - - const moveSectionUp = React.useCallback( - (sectionId: string) => { - if (!pubkey) return; - setStore((prev) => { - const next = swapSectionOrder(prev, sectionId, "up"); - if (!next || !writeChannelSectionsStore(pubkey, next, relayUrl)) - return prev; - managerRef.current?.publishSections(next); - return next; - }); - }, - [pubkey, relayUrl], - ); - - const moveSectionDown = React.useCallback( - (sectionId: string) => { - if (!pubkey) return; - setStore((prev) => { - const next = swapSectionOrder(prev, sectionId, "down"); - if (!next || !writeChannelSectionsStore(pubkey, next, relayUrl)) - return prev; - managerRef.current?.publishSections(next); - return next; - }); - }, - [pubkey, relayUrl], + (sectionId: string) => + edit((prev) => setRegs(prev, [[["s", sectionId, "live"], false]])), + [edit], ); const reorderSections = React.useCallback( - (orderedIds: string[]) => { - if (!pubkey) return; - setStore((prev) => { - const sections = prev.sections.map((s) => { - const newOrder = orderedIds.indexOf(s.id); - return newOrder === -1 ? s : { ...s, order: newOrder }; - }); - const next: ChannelSectionStore = { ...prev, sections }; - if (!writeChannelSectionsStore(pubkey, next, relayUrl)) return prev; - managerRef.current?.publishSections(next); - return next; - }); - }, - [pubkey, relayUrl], + (orderedIds: string[]) => edit((prev) => writeOrder(prev, orderedIds)), + [edit], + ); + + const moveSection = React.useCallback( + (sectionId: string, step: -1 | 1) => + edit((prev) => { + const ids = projectSections(prev).sections.map((s) => s.id); + const from = ids.indexOf(sectionId); + const to = from + step; + if (from < 0 || to < 0 || to >= ids.length) return prev; + [ids[from], ids[to]] = [ids[to] as string, sectionId]; + return writeOrder(prev, ids); + }), + [edit], + ); + const moveSectionUp = React.useCallback( + (sectionId: string) => moveSection(sectionId, -1), + [moveSection], + ); + const moveSectionDown = React.useCallback( + (sectionId: string) => moveSection(sectionId, 1), + [moveSection], ); const assignChannel = React.useCallback( - (channelId: string, sectionId: string) => { - if (!pubkey) { - return; - } - setStore((prev) => { - const assignments = { ...prev.assignments }; - delete assignments[channelId]; - assignments[channelId] = sectionId; - const next = boundChannelSectionsStore({ - ...prev, - assignments, - }); - if (!writeChannelSectionsStore(pubkey, next, relayUrl)) { - return prev; - } - managerRef.current?.publishSections(next); - return next; - }); - }, - [pubkey, relayUrl], + (channelId: string, sectionId: string) => + edit((prev) => setRegs(prev, [[["a", channelId], sectionId]])), + [edit], ); const unassignChannel = React.useCallback( - (channelId: string) => { - if (!pubkey) { - return; - } - setStore((prev) => { - const assignments = { ...prev.assignments }; - delete assignments[channelId]; - const next: ChannelSectionStore = { ...prev, assignments }; - if (!writeChannelSectionsStore(pubkey, next, relayUrl)) { - return prev; - } - managerRef.current?.publishSections(next); - return next; - }); - }, - [pubkey, relayUrl], + (channelId: string) => + edit((prev) => + prev.a && own(prev.a as Tree, channelId) + ? setRegs(prev, [[["a", channelId], null]]) + : prev, + ), + [edit], ); return { sections, - assignments: store.assignments, + assignments, createSection, renameSection, deleteSection, diff --git a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts index 85c07b1c39..0d9a997ced 100644 --- a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts +++ b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts @@ -1,191 +1,44 @@ import * as React from "react"; -import { relayClient } from "@/shared/api/relayClient"; import { - boundChannelSortStore, - DEFAULT_STORE, - readChannelSortStore, - sortModeForGroup, - storageKey, - stripOrphanedSectionModes, - writeChannelSortStore, + DEFAULT_SORT_MODE, type ChannelSortGroupKey, type ChannelSortMode, - type ChannelSortStore, } from "./channelSortPreference"; -import { ChannelSortSyncManager } from "./channelSortSync"; -import type { RemoteSortPrefs } from "./channelSortSync"; +import { SORT_LANE } from "./channelSortSync"; +import { useLaneSync } from "./sidebarLaneReconciler"; +import { isReg, own, setRegs } from "./sidebarLwwMap"; /** - * Persistent per-group sidebar sort preferences, scoped by pubkey + relay so - * they don't bleed across identities or communities (same scoping as channel - * sections). Each sidebar grouping (starred, channels, forums, dms, and each - * custom section) carries its own saved Recent/A–Z mode; unset groups default - * to A–Z. Mirrors changes made in other windows via the storage event. - * - * Preferences sync across clients via encrypted NIP-78 app data (kind 30078, - * d-tag `channel-sort`), following the channel-sections pattern: localStorage - * stays the instant/offline cache, the relay blob is the cross-client source - * of truth, and conflicts resolve with whole-blob last-write-wins. - * - * When `liveSectionIds` is provided, writes also prune `section:` entries - * whose custom section no longer exists, so deleted sections don't leave - * stale keys in localStorage. + * Persistent per-group sidebar sort preferences, scoped by pubkey + relay. + * Each grouping (starred, channels, forums, dms, and each custom section) + * carries its own Recent/A–Z mode; unset groups default to A–Z. Each group + * is its own register, so devices merge per group (see + * `sidebarLaneReconciler`) rather than overwriting the whole blob. */ export function useChannelSortPreference( pubkey: string | undefined, relayUrl?: string, - liveSectionIds?: string[], ): { sortModeFor: (group: ChannelSortGroupKey) => ChannelSortMode; setSortModeFor: (group: ChannelSortGroupKey, mode: ChannelSortMode) => void; } { - const [store, setStore] = React.useState(() => { - if (!pubkey) return DEFAULT_STORE; - return readChannelSortStore(pubkey, relayUrl); - }); - - const managerRef = React.useRef(null); - const lastAppliedRemoteTs = React.useRef(0); - const lastAppliedEventId = React.useRef(""); - - React.useEffect(() => { - if (!pubkey || !relayUrl) { - setStore(DEFAULT_STORE); - lastAppliedRemoteTs.current = 0; - lastAppliedEventId.current = ""; - return; - } - setStore(readChannelSortStore(pubkey, relayUrl)); - lastAppliedRemoteTs.current = 0; - lastAppliedEventId.current = ""; - managerRef.current = new ChannelSortSyncManager(pubkey, relayUrl); - return () => { - managerRef.current?.destroy(); - managerRef.current = null; - }; - }, [pubkey, relayUrl]); - - React.useEffect(() => { - if (!pubkey) return; - const key = storageKey(pubkey, relayUrl); - const handler = (e: StorageEvent) => { - if (e.key !== key) return; - setStore(readChannelSortStore(pubkey, relayUrl)); - }; - window.addEventListener("storage", handler); - return () => { - window.removeEventListener("storage", handler); - }; - }, [pubkey, relayUrl]); - - const applyRemote = React.useCallback( - ( - remote: RemoteSortPrefs, - ): ((prev: ChannelSortStore) => ChannelSortStore) => { - return (prev) => { - if (!pubkey) return prev; - if (remote.createdAt < lastAppliedRemoteTs.current) return prev; - if ( - remote.createdAt === lastAppliedRemoteTs.current && - remote.eventId <= lastAppliedEventId.current - ) - return prev; - lastAppliedRemoteTs.current = remote.createdAt; - lastAppliedEventId.current = remote.eventId; - managerRef.current?.cancelPendingPublish(); - if (!writeChannelSortStore(pubkey, remote.store, relayUrl)) return prev; - return remote.store; - }; - }, - [pubkey, relayUrl], - ); - - React.useEffect(() => { - if (!pubkey || !relayUrl) return; - let cancelled = false; - const local = readChannelSortStore(pubkey, relayUrl); - void managerRef.current?.bootstrap(local).then((result) => { - if (cancelled) return; - if (result.action === "apply-remote") { - setStore(applyRemote(result.data)); - } - // "hold": seed already performed by bootstrap (if first-sync), or blocked. - }); - return () => { - cancelled = true; - }; - }, [pubkey, relayUrl, applyRemote]); - - React.useEffect(() => { - if (!pubkey) return; - let unsub: (() => Promise) | null = null; - let cancelled = false; - void managerRef.current - ?.subscribeToSortPrefs((remote) => { - if (cancelled) return; - setStore(applyRemote(remote)); - }) - .then((dispose) => { - if (cancelled) { - void dispose(); - } else { - unsub = dispose; - } - }); - return () => { - cancelled = true; - if (unsub) void unsub(); - }; - }, [pubkey, applyRemote]); - - React.useEffect(() => { - if (!pubkey) return; - let cancelled = false; - const unsub = relayClient.subscribeToReconnects(() => { - void managerRef.current?.fetchRemoteSortPrefs().then((result) => { - if (cancelled) return; - if (result.status === "found") { - setStore(applyRemote(result.data)); - } - const pending = managerRef.current?.getPendingStore(); - if (pending) { - managerRef.current?.publishSortPrefs(pending); - } - }); - }); - return () => { - cancelled = true; - unsub(); - }; - }, [pubkey, applyRemote]); + const { tree, edit } = useLaneSync(SORT_LANE, pubkey, relayUrl); const sortModeFor = React.useCallback( - (group: ChannelSortGroupKey) => sortModeForGroup(store, group), - [store], + (group: ChannelSortGroupKey) => { + const reg = tree.g && !isReg(tree.g) ? own(tree.g, group) : undefined; + return isReg(reg) && reg[2] !== null + ? (reg[2] as ChannelSortMode) + : DEFAULT_SORT_MODE; + }, + [tree], ); const setSortModeFor = React.useCallback( - (group: ChannelSortGroupKey, mode: ChannelSortMode) => { - if (!pubkey) return; - setStore((prev) => { - const withUpdate: ChannelSortStore = { - ...prev, - groups: { ...prev.groups, [group]: mode }, - }; - // Prune sort modes left behind by deleted custom sections on write so - // the stored map can't grow unboundedly with stale `section:` keys. - const next = boundChannelSortStore( - liveSectionIds - ? stripOrphanedSectionModes(withUpdate, liveSectionIds) - : withUpdate, - ); - if (!writeChannelSortStore(pubkey, next, relayUrl)) return prev; - managerRef.current?.publishSortPrefs(next); - return next; - }); - }, - [pubkey, relayUrl, liveSectionIds], + (group: ChannelSortGroupKey, mode: ChannelSortMode) => + edit((prev) => setRegs(prev, [[["g", group], mode]])), + [edit], ); return { sortModeFor, setSortModeFor }; diff --git a/desktop/src/features/sidebar/lib/useChannelStars.ts b/desktop/src/features/sidebar/lib/useChannelStars.ts index 855c8de858..0eeabe644a 100644 --- a/desktop/src/features/sidebar/lib/useChannelStars.ts +++ b/desktop/src/features/sidebar/lib/useChannelStars.ts @@ -14,6 +14,7 @@ import { } from "./channelStarsStorage"; import { ChannelStarSyncManager } from "./channelStarsSync"; import type { RemoteStars } from "./channelStarsSync"; +import { useStaleReaderRecovery } from "./useStaleReaderRecovery"; export function useChannelStars( pubkey: string | undefined, @@ -33,6 +34,9 @@ export function useChannelStars( const managerRef = React.useRef(null); const lastAppliedRemoteTs = React.useRef(0); const lastAppliedEventId = React.useRef(""); + // Local-mutation revision: incremented on every user edit so an in-flight + // retry fetch that started before the edit is discarded at apply time. + const localRevision = React.useRef(0); React.useEffect(() => { if (!pubkey || !relayUrl) { @@ -75,14 +79,16 @@ export function useChannelStars( if (remote.createdAt < lastAppliedRemoteTs.current) return prev; if ( remote.createdAt === lastAppliedRemoteTs.current && - remote.eventId <= lastAppliedEventId.current + remote.eventId >= lastAppliedEventId.current ) return prev; - lastAppliedRemoteTs.current = remote.createdAt; - lastAppliedEventId.current = remote.eventId; managerRef.current?.cancelPendingStarPublish(); const merged = mergeStores(prev, remote.store); if (!writeChannelStarsStore(pubkey, merged)) return prev; + // Advance the applied head only after the cache write succeeds so a + // failed write leaves the same head retryable on the next tick. + lastAppliedRemoteTs.current = remote.createdAt; + lastAppliedEventId.current = remote.eventId; return merged; }; }, @@ -150,6 +156,31 @@ export function useChannelStars( }; }, [pubkey, relayUrl, applyRemote]); + // Retry effect: see useStaleReaderRecovery for full behavior contract. + const retryFetch = React.useCallback( + () => managerRef.current?.fetchRemoteStars(), + [], + ); + const retryHasPending = React.useCallback( + () => managerRef.current?.getPendingStarStore() != null, + [], + ); + const retryGetRevision = React.useCallback(() => localRevision.current, []); + const retryMakeUpdater = React.useCallback( + (data: RemoteStars) => applyRemote(data), + [applyRemote], + ); + useStaleReaderRecovery({ + enabled: !!pubkey && !!relayUrl, + fetch: retryFetch, + hasPending: retryHasPending, + getRevision: retryGetRevision, + makeUpdater: retryMakeUpdater, + setStore, + pubkey, + relayUrl, + }); + // biome-ignore lint/correctness/useExhaustiveDependencies: store.channels is the relevant dep — the outer store identity can change without channels changing (e.g., on reconnect writes) const starredChannelIds = React.useMemo( () => starredChannelIdsFromStore(store), @@ -172,6 +203,7 @@ export function useChannelStars( channelId, ); if (!writeChannelStarsStore(pubkey, next)) return prev; + localRevision.current += 1; managerRef.current?.publishStars(next); return next; }); diff --git a/desktop/src/features/sidebar/lib/useStaleReaderRecovery.ts b/desktop/src/features/sidebar/lib/useStaleReaderRecovery.ts new file mode 100644 index 0000000000..2927db60e5 --- /dev/null +++ b/desktop/src/features/sidebar/lib/useStaleReaderRecovery.ts @@ -0,0 +1,158 @@ +import React from "react"; + +import type { FetchResult } from "./sidebarSyncWatermark.ts"; + +/** + * Shared bounded-backoff retry effect for all four sidebar sync hooks. + * + * Polls the relay at 5 → 10 → 30 → 60 s (steady at 60 s) so a stale view + * recovers without an edit-kick or reconnect. Fires immediately on + * `visibilitychange` to "visible" so returning from background converges + * quickly. Single-flight: skips ticks while a fetch is in progress. + * + * **Pending-first guard (apply-time, not response-time):** + * `hasPending()` and the effect lifetime are re-evaluated inside the React + * state updater, after `fetch()` resolves but before `applyRemote` runs. + * This closes the window where a local edit updater queued before the remote + * response could be executed after it: React may execute queued updaters in + * the same batch, so checking only at response time is insufficient. + * + * **Mutation revision guard:** + * A revision counter is snapshotted at the start of each `fetch()` call and + * rechecked inside the updater. If a local mutation landed while the request + * was in flight the response is discarded — no old relay read can roll back a + * successfully published local edit. + * + * Both guards apply inside `makeUpdater(data)`, the lane-supplied function + * that wraps `applyRemote` and `setStore`. The checks happen before + * `applyRemote`'s returned updater runs, so `cancelPending*Publish` inside + * `applyRemote` is never reached on a fenced path. + * + * Residual: a pending edit whose publish permanently fails and is never + * resolved by a reconnect will defer recovery indefinitely. Main today has + * zero recovery in that state, so this is not a regression. + * + * @param enabled Must be true for the effect to activate + * (e.g. `!!pubkey && !!relayUrl`). + * @param fetch Calls the manager's `fetchRemote*()` for this lane. + * @param hasPending Returns true when a local unpublished edit is in + * flight. Called inside the state updater. + * @param getRevision Returns the current local-mutation revision counter. + * Called at fetch start (snapshot) and inside the + * updater (recheck). + * @param makeUpdater Given the fetched data, returns a React state updater + * that applies `applyRemote(data)` to the previous + * store. Called only when both guards pass. + * @param setStore The hook's React state setter; called with the fenced + * updater produced by `makeUpdater`. + * @param pubkey Identity key — forces the recovery effect to restart + * when the active pubkey changes. Pass whenever the + * `makeUpdater` closure does not directly capture pubkey + * (e.g. if `applyRemote` only depends on pubkey but the + * outer `makeUpdater` wraps a stable ref). + * @param relayUrl Relay key — forces the recovery effect to restart when + * the relay changes. Required for stars/mutes whose + * `applyRemote` does not capture relayUrl; without it a + * still-mounted hook switching relay A→B would keep + * applying A's stale fetch after the switch. + */ +export function useStaleReaderRecovery({ + enabled, + fetch, + hasPending, + getRevision, + makeUpdater, + setStore, + pubkey, + relayUrl, +}: { + enabled: boolean; + fetch: () => Promise | undefined> | undefined; + hasPending: () => boolean; + getRevision: () => number; + makeUpdater: (data: T) => (prev: S) => S; + setStore: React.Dispatch>; + /** Passed to bind the effect lifetime to identity; see `@param pubkey`. */ + pubkey?: string | undefined; + /** Passed to bind the effect lifetime to relay; see `@param relayUrl`. */ + relayUrl?: string | undefined; +}): void { + // biome-ignore lint/correctness/useExhaustiveDependencies: pubkey and relayUrl are intentional — they bind the recovery effect lifetime to identity and relay so a still-mounted hook switching relay A→B cancels the old effect (stars/mutes applyRemote does not capture relayUrl in its deps) + React.useEffect(() => { + if (!enabled) return; + let cancelled = false; + let inFlight = false; + const BACKOFF_STEPS = [5_000, 10_000, 30_000, 60_000]; + let stepIndex = 0; + let timer: ReturnType | null = null; + + const tick = async () => { + if (cancelled || inFlight) return; + inFlight = true; + // Snapshot the local-mutation revision before the async fetch so we can + // detect a mutation that landed while the request was in flight. + const revisionAtFetch = getRevision(); + try { + // A read started while an edit is pending can return the pre-edit + // blob after the edit publishes and pending clears; skip it. + const result = hasPending() ? undefined : await fetch(); + if (!cancelled && result?.status === "found") { + const remoteUpdater = makeUpdater(result.data); + // Apply inside a state updater so hasPending() and the cancelled / + // revision guards are evaluated at execution time, not at response + // time. This closes the React-batch window where a local edit + // updater could be queued before this remote updater but execute + // after it. + setStore((prev) => { + if (cancelled) return prev; + if (hasPending()) return prev; + if (getRevision() !== revisionAtFetch) return prev; + return remoteUpdater(prev); + }); + } + } finally { + inFlight = false; + } + if (!cancelled) { + const delay = BACKOFF_STEPS[ + Math.min(stepIndex, BACKOFF_STEPS.length - 1) + ] as number; + stepIndex = Math.min(stepIndex + 1, BACKOFF_STEPS.length - 1); + timer = setTimeout(() => { + void tick(); + }, delay); + } + }; + + const onVisible = () => { + if (document.visibilityState !== "visible") return; + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + stepIndex = 0; + void tick(); + }; + + document.addEventListener("visibilitychange", onVisible); + // Kick off the first tick immediately so bootstrap failures are recovered + // without waiting a full backoff interval. + void tick(); + + return () => { + cancelled = true; + document.removeEventListener("visibilitychange", onVisible); + if (timer !== null) clearTimeout(timer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + enabled, + fetch, + hasPending, + getRevision, + makeUpdater, + setStore, + pubkey, + relayUrl, + ]); +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 93b6defe48..0bc6186e06 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -286,7 +286,6 @@ export function AppSidebar({ const { sortModeFor, setSortModeFor } = useChannelSortPreference( currentPubkey, activeCommunity?.relayUrl, - sectionIds, ); const [createSectionState, setCreateSectionState] = React.useState<{ diff --git a/desktop/src/shared/api/relayClientPublishRejection.test.mjs b/desktop/src/shared/api/relayClientPublishRejection.test.mjs index 503ac329af..14940788ef 100644 --- a/desktop/src/shared/api/relayClientPublishRejection.test.mjs +++ b/desktop/src/shared/api/relayClientPublishRejection.test.mjs @@ -42,6 +42,7 @@ Date.now = () => fakeNow; const { RelayClient } = await import("./relayClientSession.ts"); const { invokeTauri } = await import("./tauri.ts"); +const { PublishCanceledError } = await import("./relayEventPublisher.ts"); const { activateRateLimit, isRateLimited, resetRateLimitGate } = await import( "./relayRateLimitGate.ts" ); @@ -351,3 +352,25 @@ test("HTTP failure does not clear an existing WS backoff", async () => { await deliver(client, ["OK", event.id, true, ""]); assert.equal(await published, event); }); + +test("isCurrent forwarded by publishEvent cancels a rate-limited send", async () => { + reset(); + activateRateLimit(4); + const client = connectedClient(); + const event = { id: "7".repeat(64), kind: 30078 }; + let current = true; + const published = client.publishEvent( + event, + "timed out", + "send failed", + () => current, + ); + await Promise.resolve(); + assert.equal(sendAttempts.length, 0, "waits behind the gate"); + current = false; + resetRateLimitGate(); + await assert.rejects(published, PublishCanceledError); + assert.equal(sendAttempts.length, 0, "nothing reached the native send"); + assert.equal(client.pendingEvents.size, 0); + assert.equal(pendingTimers.size, 0, "no publication timeout armed"); +}); diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index fdf159411a..ce64037570 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -842,6 +842,7 @@ export class RelayClient { event: RelayEvent, timeoutMessage: string, sendErrorMessage: string, + isCurrent?: () => boolean, ) { return publishSessionEvent( { @@ -859,6 +860,7 @@ export class RelayClient { event, timeoutMessage, sendErrorMessage, + isCurrent, ); } diff --git a/desktop/src/shared/api/relayEventPublisher.ts b/desktop/src/shared/api/relayEventPublisher.ts index ff71992670..07adcda103 100644 --- a/desktop/src/shared/api/relayEventPublisher.ts +++ b/desktop/src/shared/api/relayEventPublisher.ts @@ -13,18 +13,32 @@ type PublishSession = { recoverSocketFailure: (error: unknown, fallback: string) => Error; }; -/** Publish once, with one reconnect retry, without crossing session ownership. */ +/** Typed so no relay rejection text can be mistaken for a caller's cancel. */ +export class PublishCanceledError extends Error { + constructor() { + super("Relay publish canceled by its caller."); + this.name = "PublishCanceledError"; + } +} + +/** + * Publish once, with one reconnect retry, without crossing session ownership. + * `isCurrent`, when given, is checked immediately before every socket send; a + * false result rejects with `PublishCanceledError` and sends nothing. + */ export async function publishSessionEvent( session: PublishSession, event: RelayEvent, timeoutMessage: string, sendErrorMessage: string, + isCurrent?: () => boolean, ): Promise { const publishOwnership = session.ownership(); await waitForRateLimit(); if (publishOwnership !== session.ownership()) { throw new Error("Relay disconnected for community switch."); } + if (isCurrent?.() === false) throw new PublishCanceledError(); const publishGeneration = session.generation(); return new Promise((resolve, reject) => { @@ -65,6 +79,12 @@ export async function publishSessionEvent( "Relay publish was superseded by a session change.", ); } + if (isCurrent?.() === false) { + window.clearTimeout(timeout); + session.pendingEvents.delete(event.id); + reject(new PublishCanceledError()); + return; + } await session.send(["EVENT", event], retryGeneration); } catch (retryError) { if (session.pendingEvents.get(event.id) !== pendingEvent) return; diff --git a/desktop/tests/e2e/channel-sort.spec.ts b/desktop/tests/e2e/channel-sort.spec.ts index da70147032..03fdfbe599 100644 --- a/desktop/tests/e2e/channel-sort.spec.ts +++ b/desktop/tests/e2e/channel-sort.spec.ts @@ -178,7 +178,10 @@ test.describe("per-group channel sort", () => { const stored = await page.evaluate((key) => { return JSON.parse(window.localStorage.getItem(key) ?? "null"); }, SORT_STORAGE_KEY); - expect(stored).toEqual({ version: 1, groups: { channels: "recent" } }); + expect(stored).toMatchObject({ + version: 1, + groups: { channels: "recent" }, + }); // Survives reload. await page.reload(); @@ -246,6 +249,6 @@ test.describe("per-group channel sort", () => { const stored = await page.evaluate((key) => { return JSON.parse(window.localStorage.getItem(key) ?? "null"); }, SORT_STORAGE_KEY); - expect(stored).toEqual({ version: 1, groups: { dms: "recent" } }); + expect(stored).toMatchObject({ version: 1, groups: { dms: "recent" } }); }); }); From 15d44dcceee0679979991b3100f21f01fc799693 Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Mon, 28 Sep 2026 15:12:15 -0400 Subject: [PATCH 08/12] Add owner deletion admission control plane (#7818) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds the relay and database admission boundary for owner-requested deletion of archived hosted communities. The operator endpoint records accepted owner intent with stable UUID idempotency and immutable owner/operator provenance. Admission serializes with unarchive and owner transfer, and accepted requests disappear from actionable owner listings. This keeps destructive execution out of the request path. The existing durable deletion engine remains the only executor, so this change adds no lifecycle state or second deletion mechanism. Complexity stays bounded: one durable request is both the idempotency record and the mutation fence. ### Related issue None found. ### Testing - `REDIS_URL=redis://127.0.0.1:6379 BUZZ_POSTGRES_ADMIN_URL=postgres://buzz:buzz_dev@localhost:5432/postgres PGHOST=localhost PGPORT=5432 PGUSER=buzz PGPASSWORD=buzz_dev PG_BIN_DIR=/tmp/buzz-pg-bin ./scripts/postgres-test-run.sh -p buzz-db -p buzz-relay --lib --tests` — 385 passed. - `cargo test -p buzz-db` — passed. - `cargo fmt --all -- --check` and `cargo clippy -p buzz-db -p buzz-relay --all-targets -- -D warnings` — passed. - `just desktop-test desktop-build desktop-tauri-check desktop-tauri-test web-build` — passed; Desktop ran 6,589 tests. - Full `just test` could not start uncached Keycloak dependencies because `quay.io` reset connections. Full `just ci` reached Flutter dependency resolution, then `pub.dev` reset connections. Generated with Codex --------- Signed-off-by: tornquist Signed-off-by: Codex Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Codex Co-authored-by: Claude Opus 5 Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> --- ARCHITECTURE.md | 37 + crates/buzz-db/src/lib.rs | 2 +- crates/buzz-db/src/runtime/migration.rs | 61 +- .../tests/thread_window_postgres_tests.rs | 2 +- crates/buzz-db/src/store/community.rs | 121 +- crates/buzz-db/src/store/deletion.rs | 1331 ++++++++++++++++- crates/buzz-db/src/store/relay_members.rs | 223 ++- crates/buzz-deletion/src/lib.rs | 6 +- crates/buzz-relay/src/api/operator.rs | 764 +++++++++- .../src/handlers/community_provisioning.rs | 32 +- crates/buzz-relay/src/router.rs | 4 + ...051_owner_community_deletion_admission.sql | 81 + schema/schema.sql | 30 +- 13 files changed, 2603 insertions(+), 91 deletions(-) create mode 100644 migrations/0051_owner_community_deletion_admission.sql diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ed34ee4c8d..8aecbd61cc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -14,6 +14,43 @@ EVENT, REQ, REST, media, git, search, workflow, or pub/sub handling. Unknown hosts fail closed, and NIP-98/API-token stamps must agree with the host-derived community rather than overriding it. +Deployment-root community management uses operator-signed NIP-98 HTTP requests. +`POST /operator/communities/delete` accepts only an exact normalized, archived +community whose asserted pubkey is still its owner. The caller supplies the +request UUID as the stable correlation/idempotency identity; the durable row +records owner intent, mediating operator, and acknowledgement version. Admission +returns `202` at the `submitted` stage and performs no inventory, approval, +quiescing, object-store access, or deletion execution synchronously. While that +non-aborted request exists, unarchive and ownership transfer conflict and owner +management lists suppress the archived row. Replaying the same UUID converges +to its current stage; a different UUID conflicts with the existing one-active- +request invariant until that request is aborted. + +Owner consent on this path is asserted, not proven. The mediating operator +authenticates the owner and collects the deletion acknowledgement out of band, +upstream of the relay; the request itself carries only the operator's NIP-98 +signature. The relay verifies operator authority and that the asserted pubkey +is still the community's owner, then records the owner pubkey, mediating +operator pubkey, and acknowledgement version as durable provenance for that +upstream ceremony. No owner-signed attestation is required or checked, and +owners have no self-service cancellation. Recovery is a privileged abort, +which stays open across the reversible `submitted`, `inventoried`, `approved`, +and `fenced` stages — releasing the request fence while leaving the community +archived — and closes from `drained` onward, when tenant-state destruction may +have begun. + +Manual operator handoff converges on an admitted owner request only when +`buzz-admin deletions submit --requested-by` repeats the owner pubkey recorded +on the row. Owner provenance pins `requested_by` to `owner_pubkey`, so passing +the operator's own pubkey does not converge — it conflicts with the existing +one-active-request invariant instead. + +Ownership is mutable only while a community is active. Archiving freezes the +current owner. Normal transfer and deployment-root legacy convergence take the +same community-row lock as owner-deletion admission, then reject archived, +quiescing, deleted, or deletion-pending rotation without changing membership. +Initial owner bootstrap for a newly created community remains supported. + Buzz is a Rust monorepo, licensed Apache 2.0 under Block, Inc. --- diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 27beed5173..3dac0b1ba2 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -73,7 +73,7 @@ pub use allowlist::AllowlistEntry; pub use api_token::{ApiTokenRecord, TokenSummary}; pub use community::{ ArchivedCommunityRecord, CommunityRecord, CreateCommunityWithOwnerResult, - CreatedCommunityRecord, EnsuredCommunityRecord, OwnedCommunityRecord, + CreatedCommunityRecord, EnsuredCommunityRecord, OwnedCommunityRecord, UnarchiveCommunityResult, UnarchivedCommunityRecord, }; pub use error::{DbError, Result}; diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 1b210ed0f7..66e3645915 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -705,12 +705,17 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 50); + assert_eq!(migrations.len(), 51); assert_eq!(migrations[48].version, 49); + assert_eq!(migrations[50].version, 51); assert!(migrations[48] .sql .as_str() .contains("idx_thread_metadata_window")); + assert!(migrations[50] + .sql + .as_str() + .contains("community_deletion_owner_provenance")); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1820,6 +1825,12 @@ mod postgres_tests { .expect("embedded migration 0029") .sql .as_ref(); + let migration_0051: &str = MIGRATOR + .iter() + .find(|migration| migration.version == 51) + .expect("embedded migration 0051") + .sql + .as_ref(); let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .and_then(std::path::Path::parent) @@ -1828,6 +1839,7 @@ mod postgres_tests { .expect("read schema/schema.sql"); let migration = surface(migration_0029); + let owner_admission_migration = surface(migration_0051); let schema = surface(&schema_sql); assert_eq!( @@ -1856,13 +1868,42 @@ mod postgres_tests { .functions .get(function) .unwrap_or_else(|| panic!("schema.sql is missing deletion function {function}")); - if function != "community_write_fence_excluded_table" { + if function != "community_write_fence_excluded_table" + && function != "prevent_community_deletion_request_retargeting" + { assert_eq!( in_schema, definition, "schema.sql definition of {function}() drifted from migration 0029" ); } } + assert_eq!( + schema + .functions + .get("prevent_community_deletion_request_retargeting") + .expect("schema.sql deletion retargeting guard"), + owner_admission_migration + .functions + .get("prevent_community_deletion_request_retargeting") + .expect("0051 deletion retargeting guard"), + "schema.sql must carry the latest immutable owner-provenance guard" + ); + let request_table = schema + .tables + .get("community_deletion_requests") + .expect("schema.sql deletion request table"); + for owner_provenance_fragment in [ + "request_origin text not null default 'operator'", + "owner_pubkey text", + "mediating_operator_pubkey text", + "acknowledgement_version integer", + "constraint community_deletion_owner_provenance check", + ] { + assert!( + request_table.contains(owner_provenance_fragment), + "schema.sql deletion requests are missing {owner_provenance_fragment}" + ); + } for (trigger, definition) in &migration.triggers { let in_schema = schema .triggers @@ -2512,6 +2553,22 @@ mod postgres_tests { assert_eq!(after, vec![(1, Some(true)), (30_179, None), (30_350, None)]); } + /// Migration-upgrade half of the owner-provenance contract. + /// + /// The desired-state bootstrap half lives in + /// `store::deletion::postgres_tests` and asserts the same shared case + /// table, so `schema/schema.sql` cannot admit owner rows the migration + /// path refuses (or the reverse). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn migrated_schema_enforces_owner_provenance_contract() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + run_migrations(&pool).await.expect("run migrations"); + + crate::store::deletion::owner_provenance_contract::assert_contract(&pool).await; + } + #[tokio::test] #[ignore = "requires Postgres"] async fn run_migrations_applies_consolidated_initial_schema_on_fresh_database() { diff --git a/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs b/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs index 5ca0185af5..5d51f4348d 100644 --- a/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs +++ b/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs @@ -311,7 +311,7 @@ async fn migration_schema_thread_window_prebuild_does_not_queue_behind_writer() production_result.is_ok(), "production migrator must preserve ingestion progress: {production_result:?}" ); - assert_eq!(version, 50); + assert_eq!(version, 51); assert_eq!(final_oid, oid, "prebuild must not be replaced"); assert_eq!(count, 4, "all writer witnesses must persist"); } diff --git a/crates/buzz-db/src/store/community.rs b/crates/buzz-db/src/store/community.rs index bcd38f4e5c..461348a5cd 100644 --- a/crates/buzz-db/src/store/community.rs +++ b/crates/buzz-db/src/store/community.rs @@ -81,6 +81,17 @@ pub struct UnarchivedCommunityRecord { pub host: String, } +/// Result of an owner-authorized unarchive attempt. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UnarchiveCommunityResult { + /// The community is active, with archive state cleared idempotently. + Unarchived(UnarchivedCommunityRecord), + /// Durable deletion intent exists and wins over restoration. + DeletionPending, + /// The host is absent, unavailable, or not owned by the asserted pubkey. + NotFound, +} + impl Db { /// Returns the community mapped to a normalized request host, if one exists. /// @@ -209,6 +220,10 @@ impl Db { JOIN relay_members rm ON rm.community_id = c.id WHERE rm.pubkey = $1 AND rm.role = 'owner' + AND NOT EXISTS ( + SELECT 1 FROM community_deletion_requests request + WHERE request.community_id = c.id AND request.stage <> 'aborted' + ) ORDER BY c.created_at ASC, c.host ASC "#, ) @@ -531,40 +546,69 @@ impl Db { } /// Idempotently restores a community when the asserted pubkey is its current owner. + /// + /// Locks the community row so owner-deletion admission and restoration have + /// one serial order. A non-aborted deletion request returns + /// [`UnarchiveCommunityResult::DeletionPending`] without clearing archive state. #[datastore_span(name = "unarchive_community_owned_by", system = "postgresql")] pub async fn unarchive_community_owned_by( &self, normalized_host: &str, owner_pubkey: &str, - ) -> Result> { - let mut connection = crate::observability::acquire_writer( + ) -> Result { + let connection = crate::observability::acquire_writer( &self.pool, crate::observability::WriterOperation::Authorization, ) .await?; - let row = sqlx::query( - r#"UPDATE communities c - SET archived_at = NULL - FROM relay_members rm - WHERE lower(c.host) = lower($1) - AND rm.community_id = c.id - AND lower(rm.pubkey) = lower($2) - AND rm.role = 'owner' - AND c.deletion_state = 'active' - AND c.deleted_at IS NULL - RETURNING c.id, c.host"#, + let mut tx = sqlx::Transaction::begin(connection, None).await?; + let target = sqlx::query( + "SELECT id, host FROM communities \ + WHERE lower(host) = lower($1) AND deletion_state = 'active' \ + AND deleted_at IS NULL FOR UPDATE", ) .bind(normalized_host) - .bind(owner_pubkey) - .fetch_optional(&mut *connection) + .fetch_optional(&mut *tx) .await?; - row.map(|row| { - Ok(UnarchivedCommunityRecord { - id: CommunityId::from_uuid(row.try_get("id")?), - host: row.try_get("host")?, - }) - }) - .transpose() + let Some(target) = target else { + tx.rollback().await?; + return Ok(UnarchiveCommunityResult::NotFound); + }; + let community_id: Uuid = target.try_get("id")?; + let is_owner: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM relay_members \ + WHERE community_id = $1 AND lower(pubkey) = lower($2) AND role = 'owner')", + ) + .bind(community_id) + .bind(owner_pubkey) + .fetch_one(&mut *tx) + .await?; + if !is_owner { + tx.rollback().await?; + return Ok(UnarchiveCommunityResult::NotFound); + } + let deletion_pending: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM community_deletion_requests \ + WHERE community_id = $1 AND stage <> 'aborted')", + ) + .bind(community_id) + .fetch_one(&mut *tx) + .await?; + if deletion_pending { + tx.rollback().await?; + return Ok(UnarchiveCommunityResult::DeletionPending); + } + sqlx::query("UPDATE communities SET archived_at = NULL WHERE id = $1") + .bind(community_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(UnarchiveCommunityResult::Unarchived( + UnarchivedCommunityRecord { + id: CommunityId::from_uuid(community_id), + host: target.try_get("host")?, + }, + )) } /// Returns the community that owns a channel, if the channel exists. @@ -900,22 +944,26 @@ mod postgres_tests { .is_none(), "archived communities must fail admission" ); - assert!(db - .unarchive_community_owned_by(&host, &outsider) - .await - .expect("wrong-owner unarchive") - .is_none()); - assert!(db - .unarchive_community_owned_by("missing.example", &owner) - .await - .expect("unknown-host unarchive") - .is_none()); + assert_eq!( + db.unarchive_community_owned_by(&host, &outsider) + .await + .expect("wrong-owner unarchive"), + UnarchiveCommunityResult::NotFound + ); + assert_eq!( + db.unarchive_community_owned_by("missing.example", &owner) + .await + .expect("unknown-host unarchive"), + UnarchiveCommunityResult::NotFound + ); let restored = db .unarchive_community_owned_by(&host.to_ascii_uppercase(), &owner) .await - .expect("unarchive community") - .expect("owned community"); + .expect("unarchive community"); + let UnarchiveCommunityResult::Unarchived(restored) = restored else { + panic!("expected owned community") + }; assert_eq!(restored.id, created.id); assert_eq!(restored.host, host); assert_eq!( @@ -938,9 +986,8 @@ mod postgres_tests { let retry = db .unarchive_community_owned_by(&host, &owner) .await - .expect("idempotent retry") - .expect("owned community"); - assert_eq!(retry, restored); + .expect("idempotent retry"); + assert_eq!(retry, UnarchiveCommunityResult::Unarchived(restored)); } #[tokio::test] diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs index 0e184e00d8..cdd13d94c8 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -27,6 +27,8 @@ pub const POSTGRES_STORE_NAME: &str = "postgres"; pub const OBJECT_STORE_NAME: &str = "object_store"; /// Durable name of the Redis/cache manifest component. pub const REDIS_STORE_NAME: &str = "redis"; +/// Owner acknowledgement contract accepted by the first self-serve deletion API. +pub const OWNER_DELETION_ACKNOWLEDGEMENT_VERSION: i32 = 1; /// Deployment-global advisory-lock key serializing schema migration with /// destructive deletion. @@ -220,7 +222,7 @@ impl FromStr for DeletionStage { } /// Durable community deletion request. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] pub struct DeletionRequest { /// Request identifier. pub id: Uuid, @@ -233,8 +235,16 @@ pub struct DeletionRequest { pub stage: DeletionStage, /// Stage at which the current consecutive retry streak started. pub retry_stage: Option, - /// Operator identity that submitted the request. + /// Legacy display identity that submitted the request. pub requested_by: String, + /// Whether the request originated from an operator or authenticated owner intent. + pub request_origin: DeletionRequestOrigin, + /// Current owner identity authenticated at owner-request admission. + pub owner_pubkey: Option, + /// Deployment operator that mediated the authenticated owner intent. + pub mediating_operator_pubkey: Option, + /// Owner-facing destructive-action acknowledgement contract version. + pub acknowledgement_version: Option, /// Optional request reason. pub reason: Option, /// Frozen catalog manifest. @@ -283,6 +293,47 @@ pub struct DeletionRequest { pub completed_at: Option>, } +/// Durable provenance class for a community deletion request. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DeletionRequestOrigin { + /// Request was submitted directly by a deployment operator. + Operator, + /// Request records authenticated owner intent mediated by an operator. + Owner, +} + +impl FromStr for DeletionRequestOrigin { + type Err = DbError; + + fn from_str(value: &str) -> std::result::Result { + match value { + "operator" => Ok(Self::Operator), + "owner" => Ok(Self::Owner), + other => Err(DbError::InvalidData(format!( + "unknown community deletion request origin: {other}" + ))), + } + } +} + +/// Result of atomically admitting authenticated owner deletion intent. +#[derive(Debug, Clone, PartialEq)] +pub enum OwnerDeletionAdmission { + /// A new request was created or the stable request UUID converged to its row. + Accepted(Box), + /// The exact host is absent or the asserted owner is no longer current. + NotFoundOrNotOwner, + /// The community exists and is current-owner controlled, but is not archived. + NotArchived, + /// The community is already quiescing, fenced, or deleted. + LifecycleConflict, + /// The request UUID targets different intent, or another active request exists. + RequestConflict, + /// The owner acknowledgement contract is not supported. + UnsupportedAcknowledgementVersion, +} + /// Frozen PostgreSQL catalog inventory. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct SchemaManifest { @@ -697,6 +748,12 @@ impl DeletionStore { } /// Persist a request. Only active non-tombstone communities may be submitted. + /// + /// `requested_by` is recorded on a new row and is also the convergence key + /// when a `submitted` request already exists. Owner provenance pins an + /// owner-origin request's `requested_by` to its `owner_pubkey`, so taking + /// one over manually means passing that owner pubkey; the operator's own + /// pubkey conflicts with the existing request instead of converging. pub async fn submit( &self, community_host: &str, @@ -742,6 +799,137 @@ impl DeletionStore { } } + /// Atomically admit an archived current owner's deletion intent. + /// + /// `request_id` is both the durable request id and the caller's stable + /// correlation/idempotency identity. Replays return the existing request at + /// its current stage. This operation only persists intent; it never + /// inventories, approves, quiesces, or executes deletion. + /// + /// The owner's consent arrives as the calling operator's assertion: the + /// operator authenticated the owner and collected the acknowledgement + /// upstream. This layer records that provenance and checks that + /// `owner_pubkey` is still the community's owner; it never verifies an + /// owner-signed attestation. + pub async fn admit_owner_request( + &self, + normalized_community_host: &str, + owner_pubkey: &str, + mediating_operator_pubkey: &str, + acknowledgement_version: i32, + request_id: Uuid, + ) -> Result { + if acknowledgement_version != OWNER_DELETION_ACKNOWLEDGEMENT_VERSION { + return Ok(OwnerDeletionAdmission::UnsupportedAcknowledgementVersion); + } + let owner_pubkey = owner_pubkey.to_ascii_lowercase(); + let mediating_operator_pubkey = mediating_operator_pubkey.to_ascii_lowercase(); + let mut tx = self.pool.begin().await?; + + sqlx::query( + "SELECT pg_advisory_xact_lock(hashtextextended('buzz-owner-deletion-intent:' || $1::text, 0))", + ) + .bind(request_id) + .execute(&mut *tx) + .await?; + + if let Some(row) = sqlx::query("SELECT * FROM community_deletion_requests WHERE id = $1") + .bind(request_id) + .fetch_optional(&mut *tx) + .await? + { + let existing = row_to_request(row)?; + let converges = existing.community_host == normalized_community_host + && existing.request_origin == DeletionRequestOrigin::Owner + && existing.owner_pubkey.as_deref() == Some(owner_pubkey.as_str()) + && existing.acknowledgement_version == Some(acknowledgement_version); + tx.rollback().await?; + return Ok(if converges { + OwnerDeletionAdmission::Accepted(Box::new(existing)) + } else { + OwnerDeletionAdmission::RequestConflict + }); + } + + let target = sqlx::query( + "SELECT id, host, archived_at, deletion_state, deleted_at \ + FROM communities WHERE host = $1 FOR UPDATE", + ) + .bind(normalized_community_host) + .fetch_optional(&mut *tx) + .await?; + let Some(target) = target else { + tx.rollback().await?; + return Ok(OwnerDeletionAdmission::NotFoundOrNotOwner); + }; + let community_id: Uuid = target.try_get("id")?; + let canonical_host: String = target.try_get("host")?; + let deletion_state: String = target.try_get("deletion_state")?; + let deleted_at: Option> = target.try_get("deleted_at")?; + if deletion_state != "active" || deleted_at.is_some() { + tx.rollback().await?; + return Ok(OwnerDeletionAdmission::LifecycleConflict); + } + + let owner_exists = sqlx::query_scalar::<_, String>( + "SELECT pubkey FROM relay_members \ + WHERE community_id = $1 AND pubkey = $2 AND role = 'owner' FOR UPDATE", + ) + .bind(community_id) + .bind(&owner_pubkey) + .fetch_optional(&mut *tx) + .await? + .is_some(); + if !owner_exists { + tx.rollback().await?; + return Ok(OwnerDeletionAdmission::NotFoundOrNotOwner); + } + if target + .try_get::>, _>("archived_at")? + .is_none() + { + tx.rollback().await?; + return Ok(OwnerDeletionAdmission::NotArchived); + } + let active_request_exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM community_deletion_requests \ + WHERE community_id = $1 AND stage <> 'aborted')", + ) + .bind(community_id) + .fetch_one(&mut *tx) + .await?; + if active_request_exists { + tx.rollback().await?; + return Ok(OwnerDeletionAdmission::RequestConflict); + } + + let row = sqlx::query( + r#" + INSERT INTO community_deletion_requests ( + id, community_id, community_host, requested_by, request_origin, + owner_pubkey, mediating_operator_pubkey, acknowledgement_version + ) VALUES ($1, $2, $3, $4, 'owner', $4, $5, $6) + ON CONFLICT (community_id) WHERE stage <> 'aborted' DO NOTHING + RETURNING * + "#, + ) + .bind(request_id) + .bind(community_id) + .bind(canonical_host) + .bind(owner_pubkey) + .bind(mediating_operator_pubkey) + .bind(acknowledgement_version) + .fetch_optional(&mut *tx) + .await?; + let Some(row) = row else { + tx.rollback().await?; + return Ok(OwnerDeletionAdmission::RequestConflict); + }; + let request = row_to_request(row)?; + tx.commit().await?; + Ok(OwnerDeletionAdmission::Accepted(Box::new(request))) + } + /// List requests newest first with a hard bound. pub async fn list(&self, limit: i64) -> Result> { let rows = sqlx::query( @@ -2010,7 +2198,17 @@ impl DeletionStore { Ok(()) } - /// Terminally abort an approved or fenced request before object deletion begins. + /// Terminally abort a request at the reversible pre-destruction boundary. + /// + /// `submitted`, `inventoried`, `approved`, and `fenced` are reversible: + /// nothing tenant-visible has been destroyed, so abort releases the durable + /// request fence over owner listing, unarchive, and owner rotation. Owner + /// admission deliberately has no owner-facing cancellation, so this + /// privileged path is the only recovery when preparation cannot continue. + /// Abort reverses deletion intent, not the owner's archive decision: the + /// community stays archived and the owner restores it explicitly. + /// Stages from `drained` onward stay closed because tenant-state destruction + /// may have begun. pub async fn abort( &self, request_id: Uuid, @@ -2049,7 +2247,10 @@ impl DeletionStore { } if !matches!( request.stage, - DeletionStage::Approved | DeletionStage::Fenced + DeletionStage::Submitted + | DeletionStage::Inventoried + | DeletionStage::Approved + | DeletionStage::Fenced ) { return Err(DbError::DeletionSafety(format!( "deletion {request_id} at stage {} cannot be aborted", @@ -2674,7 +2875,7 @@ async fn lock_community_deletion( Ok(()) } -async fn lock_community_deletion_shared( +pub(crate) async fn lock_community_deletion_shared( tx: &mut Transaction<'_, Postgres>, community: CommunityId, ) -> Result<()> { @@ -3112,6 +3313,10 @@ fn row_to_request(row: sqlx::postgres::PgRow) -> Result { .map(|stage| stage.parse()) .transpose()?, requested_by: row.try_get("requested_by")?, + request_origin: row.try_get::("request_origin")?.parse()?, + owner_pubkey: row.try_get("owner_pubkey")?, + mediating_operator_pubkey: row.try_get("mediating_operator_pubkey")?, + acknowledgement_version: row.try_get("acknowledgement_version")?, reason: row.try_get("reason")?, schema_manifest: row.try_get("schema_manifest")?, storage_manifest: row.try_get("storage_manifest")?, @@ -3413,10 +3618,216 @@ mod tests { } } +/// Shared `community_deletion_owner_provenance` contract cases. +/// +/// The same table is asserted against the migration-upgrade schema +/// (`runtime::migration::postgres_tests`) and the desired-state bootstrap +/// schema (`postgres_tests` below), so the two schema sources cannot drift +/// into different owner-provenance guarantees. +#[cfg(test)] +pub(crate) mod owner_provenance_contract { + use sqlx::{PgPool, Row}; + use uuid::Uuid; + + /// Constraint that must reject every malformed owner-provenance row. + pub(crate) const CONSTRAINT: &str = "community_deletion_owner_provenance"; + + const VALID_OWNER: &str = "1111111111111111111111111111111111111111111111111111111111111111"; + const VALID_OPERATOR: &str = "2222222222222222222222222222222222222222222222222222222222222222"; + + /// One rejected owner-origin row: `(case name, owner, mediator, ack, requested_by)`. + type Case = ( + &'static str, + Option<&'static str>, + Option<&'static str>, + Option, + &'static str, + ); + + /// Every owner-origin row the constraint must refuse. + pub(crate) fn rejected_cases() -> Vec { + vec![ + ( + "missing owner_pubkey", + None, + Some(VALID_OPERATOR), + Some(1), + VALID_OWNER, + ), + ( + "missing mediating_operator_pubkey", + Some(VALID_OWNER), + None, + Some(1), + VALID_OWNER, + ), + ( + "missing acknowledgement_version", + Some(VALID_OWNER), + Some(VALID_OPERATOR), + None, + VALID_OWNER, + ), + ( + "owner_pubkey too short", + Some("abc"), + Some(VALID_OPERATOR), + Some(1), + "abc", + ), + ( + "owner_pubkey uppercase hex", + Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), + Some(VALID_OPERATOR), + Some(1), + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ), + ( + "owner_pubkey non-hex", + Some("zzzz111111111111111111111111111111111111111111111111111111111111"), + Some(VALID_OPERATOR), + Some(1), + "zzzz111111111111111111111111111111111111111111111111111111111111", + ), + ( + "mediating_operator_pubkey too short", + Some(VALID_OWNER), + Some("abc"), + Some(1), + VALID_OWNER, + ), + ( + "mediating_operator_pubkey uppercase hex", + Some(VALID_OWNER), + Some("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"), + Some(1), + VALID_OWNER, + ), + ( + "acknowledgement_version zero", + Some(VALID_OWNER), + Some(VALID_OPERATOR), + Some(0), + VALID_OWNER, + ), + ( + "acknowledgement_version negative", + Some(VALID_OWNER), + Some(VALID_OPERATOR), + Some(-1), + VALID_OWNER, + ), + ( + "requested_by is not the owner", + Some(VALID_OWNER), + Some(VALID_OPERATOR), + Some(1), + VALID_OPERATOR, + ), + ] + } + + async fn seed_community(pool: &PgPool) -> Uuid { + let host = format!("owner-provenance-{}.example", Uuid::new_v4().simple()); + sqlx::query_scalar::<_, Uuid>("INSERT INTO communities (host) VALUES ($1) RETURNING id") + .bind(&host) + .fetch_one(pool) + .await + .expect("seed owner-provenance community") + } + + async fn insert_owner_row( + pool: &PgPool, + owner: Option<&str>, + mediator: Option<&str>, + acknowledgement: Option, + requested_by: &str, + ) -> Result<(), sqlx::Error> { + let community_id = seed_community(pool).await; + let host: String = sqlx::query("SELECT host FROM communities WHERE id = $1") + .bind(community_id) + .fetch_one(pool) + .await + .expect("seeded host") + .try_get("host") + .expect("host column"); + sqlx::query( + "INSERT INTO community_deletion_requests \ + (id, community_id, community_host, requested_by, request_origin, \ + owner_pubkey, mediating_operator_pubkey, acknowledgement_version) \ + VALUES ($1, $2, $3, $4, 'owner', $5, $6, $7)", + ) + .bind(Uuid::new_v4()) + .bind(community_id) + .bind(host) + .bind(requested_by) + .bind(owner) + .bind(mediator) + .bind(acknowledgement) + .execute(pool) + .await + .map(|_| ()) + } + + /// Assert the live schema refuses every malformed owner row and still + /// admits a well-formed one. + pub(crate) async fn assert_contract(pool: &PgPool) { + for (name, owner, mediator, acknowledgement, requested_by) in rejected_cases() { + let error = insert_owner_row(pool, owner, mediator, acknowledgement, requested_by) + .await + .expect_err(&format!("owner-provenance case must be rejected: {name}")); + let constraint = error + .as_database_error() + .and_then(sqlx::error::DatabaseError::constraint); + assert_eq!( + constraint, + Some(CONSTRAINT), + "case {name:?} must fail {CONSTRAINT}, got: {error}" + ); + } + + // Falsifiability: the constraint must still admit well-formed intent. + insert_owner_row( + pool, + Some(VALID_OWNER), + Some(VALID_OPERATOR), + Some(1), + VALID_OWNER, + ) + .await + .expect("well-formed owner provenance must be accepted"); + + // The operator branch stays exclusive of owner columns. + let community_id = seed_community(pool).await; + let operator_with_owner_columns = sqlx::query( + "INSERT INTO community_deletion_requests \ + (id, community_id, community_host, requested_by, request_origin, owner_pubkey) \ + VALUES ($1, $2, 'operator.example', 'operator', 'operator', $3)", + ) + .bind(Uuid::new_v4()) + .bind(community_id) + .bind(VALID_OWNER) + .execute(pool) + .await; + assert_eq!( + operator_with_owner_columns + .expect_err("operator-origin rows must not carry owner provenance") + .as_database_error() + .and_then(sqlx::error::DatabaseError::constraint), + Some(CONSTRAINT) + ); + } +} + #[cfg(test)] mod postgres_tests { use super::*; - use crate::{CreateCommunityWithOwnerResult, Db, DbConfig}; + use crate::{ + relay_members::{ProvisionOwnerResult, TransferResult}, + CreateCommunityWithOwnerResult, Db, DbConfig, UnarchiveCommunityResult, + }; + use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; + use std::str::FromStr; async fn store() -> (Db, DeletionStore) { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") @@ -3485,6 +3896,914 @@ mod postgres_tests { (request, inventory) } + async fn archived_owned_community(db: &Db) -> (String, String, CommunityId) { + let host = format!("owner-delete-{}.example", Uuid::new_v4().simple()); + let owner = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let CreateCommunityWithOwnerResult::Created(created) = db + .create_community_with_owner(&host, &owner) + .await + .expect("create owned community") + else { + panic!("expected a fresh community") + }; + db.archive_community_owned_by(&host, &owner, "protected.example") + .await + .expect("archive community") + .expect("owned community"); + (host, owner, created.id) + } + + struct AdmissionInsertGate { + connection: sqlx::pool::PoolConnection, + trigger_name: String, + function_name: String, + first_key: i32, + second_key: i32, + } + + async fn install_admission_insert_gate(db: &Db, request_id: Uuid) -> AdmissionInsertGate { + let suffix = Uuid::new_v4().simple().to_string(); + let trigger_name = format!("owner_admission_gate_{suffix}"); + let function_name = format!("owner_admission_gate_fn_{suffix}"); + let first_key = (request_id.as_u128() as u32 & 0x7fff_ffff) as i32; + let second_key = ((request_id.as_u128() >> 32) as u32 & 0x7fff_ffff) as i32; + let mut connection = db.pool.acquire().await.expect("acquire gate connection"); + sqlx::query("SELECT pg_advisory_lock(712345, 193847)") + .execute(&mut *connection) + .await + .expect("serialize admission gate fixtures"); + sqlx::query("SELECT pg_advisory_lock($1, $2)") + .bind(first_key) + .bind(second_key) + .execute(&mut *connection) + .await + .expect("hold admission gate"); + sqlx::query(AssertSqlSafe(format!( + "CREATE FUNCTION {function_name}() RETURNS trigger LANGUAGE plpgsql AS $$ \ + BEGIN \ + IF NEW.id = '{request_id}'::uuid THEN \ + PERFORM pg_advisory_xact_lock({first_key}, {second_key}); \ + END IF; \ + RETURN NEW; \ + END $$" + ))) + .execute(&db.pool) + .await + .expect("install admission gate function"); + sqlx::query(AssertSqlSafe(format!( + "CREATE TRIGGER {trigger_name} BEFORE INSERT ON community_deletion_requests \ + FOR EACH ROW EXECUTE FUNCTION {function_name}()" + ))) + .execute(&db.pool) + .await + .expect("install admission gate trigger"); + AdmissionInsertGate { + connection, + trigger_name, + function_name, + first_key, + second_key, + } + } + + async fn wait_for_admission_gate(db: &Db, gate: &AdmissionInsertGate) { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let waiting: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM pg_locks \ + WHERE locktype = 'advisory' AND classid = $1::oid AND objid = $2::oid \ + AND objsubid = 2 AND NOT granted)", + ) + .bind(gate.first_key) + .bind(gate.second_key) + .fetch_one(&db.pool) + .await + .expect("inspect admission gate"); + if waiting { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("owner admission reached gated insert"); + } + + async fn release_admission_insert_gate(gate: &mut AdmissionInsertGate) { + sqlx::query("SELECT pg_advisory_unlock($1, $2)") + .bind(gate.first_key) + .bind(gate.second_key) + .execute(&mut *gate.connection) + .await + .expect("release admission gate"); + } + + async fn remove_admission_insert_gate(db: &Db, mut gate: AdmissionInsertGate) { + sqlx::query(AssertSqlSafe(format!( + "DROP TRIGGER {} ON community_deletion_requests", + gate.trigger_name + ))) + .execute(&db.pool) + .await + .expect("remove admission gate trigger"); + sqlx::query(AssertSqlSafe(format!( + "DROP FUNCTION {}()", + gate.function_name + ))) + .execute(&db.pool) + .await + .expect("remove admission gate function"); + sqlx::query("SELECT pg_advisory_unlock(712345, 193847)") + .execute(&mut *gate.connection) + .await + .expect("release admission fixture serialization"); + } + + struct OwnerConvergenceGate { + connection: sqlx::pool::PoolConnection, + trigger_name: String, + function_name: String, + first_key: i32, + second_key: i32, + } + + async fn install_owner_convergence_gate( + db: &Db, + community: CommunityId, + owner: &str, + ) -> OwnerConvergenceGate { + let suffix = Uuid::new_v4().simple().to_string(); + let trigger_name = format!("a_owner_convergence_gate_{suffix}"); + let function_name = format!("owner_convergence_gate_fn_{suffix}"); + let gate_id = Uuid::new_v4(); + let first_key = (gate_id.as_u128() as u32 & 0x7fff_ffff) as i32; + let second_key = ((gate_id.as_u128() >> 32) as u32 & 0x7fff_ffff) as i32; + let mut connection = db.pool.acquire().await.expect("acquire gate connection"); + sqlx::query("SELECT pg_advisory_lock($1, $2)") + .bind(first_key) + .bind(second_key) + .execute(&mut *connection) + .await + .expect("hold owner convergence gate"); + sqlx::query(AssertSqlSafe(format!( + "CREATE FUNCTION {function_name}() RETURNS trigger LANGUAGE plpgsql AS $$ \ + BEGIN \ + IF NEW.community_id = '{}'::uuid AND NEW.pubkey = '{}' THEN \ + PERFORM pg_advisory_xact_lock({first_key}, {second_key}); \ + END IF; \ + RETURN NEW; \ + END $$", + community.as_uuid(), + owner + ))) + .execute(&db.pool) + .await + .expect("install owner convergence gate function"); + sqlx::query(AssertSqlSafe(format!( + "CREATE TRIGGER {trigger_name} BEFORE INSERT ON relay_members \ + FOR EACH ROW EXECUTE FUNCTION {function_name}()" + ))) + .execute(&db.pool) + .await + .expect("install owner convergence gate trigger"); + OwnerConvergenceGate { + connection, + trigger_name, + function_name, + first_key, + second_key, + } + } + + async fn wait_for_owner_convergence_gate(db: &Db, gate: &OwnerConvergenceGate) { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let waiting: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM pg_locks \ + WHERE locktype = 'advisory' AND classid = $1::oid AND objid = $2::oid \ + AND objsubid = 2 AND NOT granted)", + ) + .bind(gate.first_key) + .bind(gate.second_key) + .fetch_one(&db.pool) + .await + .expect("inspect owner convergence gate"); + if waiting { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("owner convergence reached gated member update"); + } + + async fn release_owner_convergence_gate(gate: &mut OwnerConvergenceGate) { + sqlx::query("SELECT pg_advisory_unlock($1, $2)") + .bind(gate.first_key) + .bind(gate.second_key) + .execute(&mut *gate.connection) + .await + .expect("release owner convergence gate"); + } + + async fn remove_owner_convergence_gate(db: &Db, gate: OwnerConvergenceGate) { + sqlx::query(AssertSqlSafe(format!( + "DROP TRIGGER {} ON relay_members", + gate.trigger_name + ))) + .execute(&db.pool) + .await + .expect("remove owner convergence gate trigger"); + sqlx::query(AssertSqlSafe(format!( + "DROP FUNCTION {}()", + gate.function_name + ))) + .execute(&db.pool) + .await + .expect("remove owner convergence gate function"); + } + + async fn contender_db(application_name: &str) -> Db { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); + let options = PgConnectOptions::from_str(&database_url) + .expect("parse test database URL") + .application_name(application_name); + let pool = PgPoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .expect("connect contender DB"); + Db::from_pool(pool) + } + + async fn wait_for_contender_lock(db: &Db, application_name: &str) { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let waiting: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM pg_stat_activity \ + WHERE datname = current_database() AND application_name = $1 \ + AND wait_event_type = 'Lock')", + ) + .bind(application_name) + .fetch_one(&db.pool) + .await + .expect("inspect contender lock"); + if waiting { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("contender reached community row lock"); + } + + async fn membership_roles(db: &Db, community: CommunityId) -> Vec<(String, String)> { + sqlx::query_as( + "SELECT pubkey, role FROM relay_members WHERE community_id = $1 ORDER BY pubkey", + ) + .bind(community.as_uuid()) + .fetch_all(&db.pool) + .await + .expect("read membership roles") + } + + async fn assert_owner_admission_is_only_committed_mutation( + db: &Db, + community: CommunityId, + request_id: Uuid, + ) { + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT count(*) FROM community_deletion_requests \ + WHERE id = $1 AND community_id = $2 AND stage = 'submitted'", + ) + .bind(request_id) + .bind(community.as_uuid()) + .fetch_one(&db.pool) + .await + .expect("count admitted request"), + 1 + ); + assert!( + sqlx::query_scalar::<_, bool>( + "SELECT archived_at IS NOT NULL FROM communities WHERE id = $1", + ) + .bind(community.as_uuid()) + .fetch_one(&db.pool) + .await + .expect("read archive state"), + "the losing mutation must not clear archive state" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn owner_admission_requires_current_owner_and_archived_active_target() { + let (db, store) = store().await; + let (host, owner, community) = archived_owned_community(&db).await; + let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let request_id = Uuid::new_v4(); + + let admitted = store + .admit_owner_request(&host, &owner, operator, 1, request_id) + .await + .expect("admit archived owner request"); + let OwnerDeletionAdmission::Accepted(request) = admitted else { + panic!("expected accepted owner request") + }; + assert_eq!(request.id, request_id); + assert_eq!(request.community_id, community); + assert_eq!(request.community_host, host); + assert_eq!(request.stage, DeletionStage::Submitted); + assert_eq!(request.request_origin, DeletionRequestOrigin::Owner); + assert_eq!(request.owner_pubkey.as_deref(), Some(owner.as_str())); + assert_eq!(request.mediating_operator_pubkey.as_deref(), Some(operator)); + assert_eq!(request.acknowledgement_version, Some(1)); + assert_eq!( + sqlx::query_scalar::<_, String>("SELECT deletion_state FROM communities WHERE id = $1") + .bind(community.as_uuid()) + .fetch_one(&db.pool) + .await + .expect("community lifecycle"), + "active", + "admission must not prematurely quiesce the community" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn owner_admission_rejects_non_archived_non_owner_and_stale_owner() { + let (db, store) = store().await; + let host = format!("owner-delete-active-{}.example", Uuid::new_v4().simple()); + let owner = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let replacement = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let outsider = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let CreateCommunityWithOwnerResult::Created(created) = db + .create_community_with_owner(&host, &owner) + .await + .expect("create community") + else { + panic!("expected fresh community") + }; + let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + assert_eq!( + store + .admit_owner_request(&host, &owner, operator, 1, Uuid::new_v4()) + .await + .expect("non-archived admission result"), + OwnerDeletionAdmission::NotArchived + ); + db.archive_community_owned_by(&host, &owner, "protected.example") + .await + .expect("archive") + .expect("owned community"); + assert_eq!( + store + .admit_owner_request(&host, &outsider, operator, 1, Uuid::new_v4()) + .await + .expect("non-owner admission result"), + OwnerDeletionAdmission::NotFoundOrNotOwner + ); + assert_eq!( + db.transfer_ownership(created.id, &replacement, &owner) + .await + .expect("reject archived owner rotation"), + TransferResult::LifecycleConflict + ); + assert!(matches!( + store + .admit_owner_request(&host, &owner, operator, 1, Uuid::new_v4()) + .await + .expect("current-owner admission result"), + OwnerDeletionAdmission::Accepted(_) + )); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn accepted_owner_deletion_blocks_legacy_owner_convergence_without_membership_change() { + let (db, store) = store().await; + let (host, owner, community) = archived_owned_community(&db).await; + let replacement = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let before = membership_roles(&db, community).await; + let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + assert!(matches!( + store + .admit_owner_request(&host, &owner, operator, 1, Uuid::new_v4()) + .await + .expect("admit owner request"), + OwnerDeletionAdmission::Accepted(_) + )); + + assert_eq!( + db.provision_owner(community, &replacement) + .await + .expect("legacy convergence result"), + ProvisionOwnerResult::DeletionPending + ); + assert_eq!(membership_roles(&db, community).await, before); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn same_owner_convergence_serializes_with_abort_without_deadlock() { + let mut deadlocks = Vec::new(); + for stage in [DeletionStage::Submitted, DeletionStage::Inventoried] { + let (db, store) = store().await; + let (host, owner, community) = archived_owned_community(&db).await; + let OwnerDeletionAdmission::Accepted(request) = store + .admit_owner_request( + &host, + &owner, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + 1, + Uuid::new_v4(), + ) + .await + .expect("admit owner deletion") + else { + panic!("expected accepted owner deletion") + }; + if stage == DeletionStage::Inventoried { + let inventory = FrozenInventory { + schema: store + .inventory_schema(community) + .await + .expect("inventory schema"), + storage: empty_storage_manifest(community), + }; + let inventoried = store + .freeze_inventory(request.id, &inventory) + .await + .expect("freeze inventory"); + assert_eq!(inventoried.stage, DeletionStage::Inventoried); + } + + let mut gate = install_owner_convergence_gate(&db, community, &owner).await; + let convergence_db = + contender_db(&format!("owner-convergence-{}", Uuid::new_v4().simple())).await; + let converging = tokio::spawn({ + let owner = owner.clone(); + async move { convergence_db.provision_owner(community, &owner).await } + }); + wait_for_owner_convergence_gate(&db, &gate).await; + + let abort_application = format!("owner-abort-{}", Uuid::new_v4().simple()); + let abort_store = contender_db(&abort_application).await.deletion_store(); + let aborting = tokio::spawn(async move { + abort_store + .abort(request.id, "operator", "same-owner convergence race") + .await + }); + wait_for_contender_lock(&db, &abort_application).await; + release_owner_convergence_gate(&mut gate).await; + + let (convergence_join, abort_join) = tokio::join!( + tokio::time::timeout(Duration::from_secs(5), converging), + tokio::time::timeout(Duration::from_secs(5), aborting), + ); + remove_owner_convergence_gate(&db, gate).await; + let convergence_result = convergence_join + .expect("same-owner convergence must not hang") + .expect("join same-owner convergence"); + let abort_result = abort_join + .expect("abort must not hang") + .expect("join abort"); + if matches!( + &convergence_result, + Err(DbError::Sqlx(sqlx::Error::Database(error))) + if error.code().as_deref() == Some("40P01") + ) || matches!( + &abort_result, + Err(DbError::Sqlx(sqlx::Error::Database(error))) + if error.code().as_deref() == Some("40P01") + ) { + deadlocks.push(format!( + "{stage}: convergence={convergence_result:?}, abort={abort_result:?}" + )); + continue; + } + assert_eq!( + convergence_result.expect("same-owner convergence"), + ProvisionOwnerResult::Applied + ); + assert_eq!( + abort_result.expect("abort request").stage, + DeletionStage::Aborted + ); + } + assert!( + deadlocks.is_empty(), + "same-owner convergence and abort must serialize without 40P01:\n{}", + deadlocks.join("\n") + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn concurrent_owner_admission_serializes_before_normal_transfer() { + let (db, store) = store().await; + let (host, owner, community) = archived_owned_community(&db).await; + let replacement = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let before = membership_roles(&db, community).await; + let request_id = Uuid::new_v4(); + let mut gate = install_admission_insert_gate(&db, request_id).await; + let admission = tokio::spawn({ + let store = store.clone(); + let host = host.clone(); + let owner = owner.clone(); + async move { + store + .admit_owner_request( + &host, + &owner, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + 1, + request_id, + ) + .await + } + }); + wait_for_admission_gate(&db, &gate).await; + + let application_name = format!("owner-transfer-{}", Uuid::new_v4().simple()); + let contender = contender_db(&application_name).await; + let transfer = tokio::spawn({ + let owner = owner.clone(); + let replacement = replacement.clone(); + async move { + contender + .transfer_ownership(community, &replacement, &owner) + .await + } + }); + wait_for_contender_lock(&db, &application_name).await; + release_admission_insert_gate(&mut gate).await; + + let admission_result = admission.await.expect("join admission").expect("admission"); + let transfer_result = transfer.await.expect("join transfer").expect("transfer"); + remove_admission_insert_gate(&db, gate).await; + assert!(matches!( + admission_result, + OwnerDeletionAdmission::Accepted(_) + )); + assert_eq!(transfer_result, TransferResult::DeletionPending); + assert_eq!(membership_roles(&db, community).await, before); + assert_owner_admission_is_only_committed_mutation(&db, community, request_id).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn concurrent_owner_admission_serializes_before_unarchive() { + let (db, store) = store().await; + let (host, owner, community) = archived_owned_community(&db).await; + let before = membership_roles(&db, community).await; + let request_id = Uuid::new_v4(); + let mut gate = install_admission_insert_gate(&db, request_id).await; + let admission = tokio::spawn({ + let store = store.clone(); + let host = host.clone(); + let owner = owner.clone(); + async move { + store + .admit_owner_request( + &host, + &owner, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + 1, + request_id, + ) + .await + } + }); + wait_for_admission_gate(&db, &gate).await; + + let application_name = format!("owner-unarchive-{}", Uuid::new_v4().simple()); + let contender = contender_db(&application_name).await; + let unarchive = tokio::spawn({ + let host = host.clone(); + let owner = owner.clone(); + async move { contender.unarchive_community_owned_by(&host, &owner).await } + }); + wait_for_contender_lock(&db, &application_name).await; + release_admission_insert_gate(&mut gate).await; + + let admission_result = admission.await.expect("join admission").expect("admission"); + let unarchive_result = unarchive.await.expect("join unarchive").expect("unarchive"); + remove_admission_insert_gate(&db, gate).await; + assert!(matches!( + admission_result, + OwnerDeletionAdmission::Accepted(_) + )); + assert_eq!(unarchive_result, UnarchiveCommunityResult::DeletionPending); + assert_eq!(membership_roles(&db, community).await, before); + assert_owner_admission_is_only_committed_mutation(&db, community, request_id).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn concurrent_owner_admission_serializes_before_legacy_owner_rotation() { + let (db, store) = store().await; + let (host, owner, community) = archived_owned_community(&db).await; + let replacement = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let before = membership_roles(&db, community).await; + let request_id = Uuid::new_v4(); + let mut gate = install_admission_insert_gate(&db, request_id).await; + let admission = tokio::spawn({ + let store = store.clone(); + let host = host.clone(); + let owner = owner.clone(); + async move { + store + .admit_owner_request( + &host, + &owner, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + 1, + request_id, + ) + .await + } + }); + wait_for_admission_gate(&db, &gate).await; + + let application_name = format!("owner-legacy-{}", Uuid::new_v4().simple()); + let contender = contender_db(&application_name).await; + let provision = tokio::spawn({ + let replacement = replacement.clone(); + async move { contender.provision_owner(community, &replacement).await } + }); + wait_for_contender_lock(&db, &application_name).await; + release_admission_insert_gate(&mut gate).await; + + let admission_result = admission.await.expect("join admission").expect("admission"); + let provision_result = provision + .await + .expect("join legacy convergence") + .expect("legacy convergence"); + remove_admission_insert_gate(&db, gate).await; + assert!(matches!( + admission_result, + OwnerDeletionAdmission::Accepted(_) + )); + assert_eq!(provision_result, ProvisionOwnerResult::DeletionPending); + assert_eq!(membership_roles(&db, community).await, before); + assert_owner_admission_is_only_committed_mutation(&db, community, request_id).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn owner_admission_replay_converges_after_stage_advancement_and_rejects_retargeting() { + let (db, store) = store().await; + let (host, owner, _) = archived_owned_community(&db).await; + let (other_host, other_owner, _) = archived_owned_community(&db).await; + let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let request_id = Uuid::new_v4(); + let OwnerDeletionAdmission::Accepted(first) = store + .admit_owner_request(&host, &owner, operator, 1, request_id) + .await + .expect("first admission") + else { + panic!("expected accepted request") + }; + let inventory = FrozenInventory { + schema: store + .inventory_schema(first.community_id) + .await + .expect("schema inventory"), + storage: empty_storage_manifest(first.community_id), + }; + store + .freeze_inventory(first.id, &inventory) + .await + .expect("advance request"); + + let OwnerDeletionAdmission::Accepted(replayed) = store + .admit_owner_request(&host, &owner, operator, 1, request_id) + .await + .expect("replay admission") + else { + panic!("expected converged replay") + }; + assert_eq!(replayed.id, first.id); + assert_eq!(replayed.stage, DeletionStage::Inventoried); + assert_eq!( + store + .admit_owner_request(&other_host, &other_owner, operator, 1, request_id) + .await + .expect("retargeting result"), + OwnerDeletionAdmission::RequestConflict + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn concurrent_owner_admission_duplicates_return_one_stable_request() { + let (db, store) = store().await; + let (host, owner, community) = archived_owned_community(&db).await; + let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let request_id = Uuid::new_v4(); + let (first, second) = tokio::join!( + store.admit_owner_request(&host, &owner, operator, 1, request_id), + store.admit_owner_request(&host, &owner, operator, 1, request_id), + ); + let accepted_id = |result: Result| { + let OwnerDeletionAdmission::Accepted(request) = result.expect("admission") else { + panic!("expected accepted request") + }; + request.id + }; + assert_eq!(accepted_id(first), request_id); + assert_eq!(accepted_id(second), request_id); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT count(*) FROM community_deletion_requests WHERE community_id = $1 AND stage <> 'aborted'" + ) + .bind(community.as_uuid()) + .fetch_one(&db.pool) + .await + .expect("active request count"), + 1 + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn accepted_owner_request_blocks_unarchive_and_transfer_and_suppresses_owner_list_row() { + let (db, store) = store().await; + let (host, owner, community) = archived_owned_community(&db).await; + let new_owner = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let OwnerDeletionAdmission::Accepted(_) = store + .admit_owner_request(&host, &owner, operator, 1, Uuid::new_v4()) + .await + .expect("admit owner request") + else { + panic!("expected accepted request") + }; + + assert_eq!( + db.unarchive_community_owned_by(&host, &owner) + .await + .expect("unarchive result"), + UnarchiveCommunityResult::DeletionPending + ); + assert_eq!( + db.transfer_ownership(community, &new_owner, &owner) + .await + .expect("transfer result"), + TransferResult::DeletionPending + ); + assert!( + db.list_communities_owned_by(&owner) + .await + .expect("owner list") + .iter() + .all(|row| row.id != community), + "accepted deletion requests must not remain actionable archived rows" + ); + } + + /// Desired-state bootstrap half of the owner-provenance contract. + /// + /// The migration-upgrade half lives in `runtime::migration::postgres_tests` + /// and asserts the same shared case table. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn desired_state_schema_enforces_owner_provenance_contract() { + let (db, _) = store().await; + owner_provenance_contract::assert_contract(&db.pool).await; + } + + /// Privileged recovery abort at the reversible pre-approval boundary. + /// + /// Owner admission has no owner-facing cancellation. When preparation + /// cannot safely continue, an operator aborts the request; that must + /// release the durable request fence over listing, unarchive, and owner + /// rotation without silently unarchiving the community behind the owner. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn privileged_abort_of_submitted_owner_request_releases_the_request_fence() { + let (db, store) = store().await; + let (host, owner, community) = archived_owned_community(&db).await; + let new_owner = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let request_id = Uuid::new_v4(); + let OwnerDeletionAdmission::Accepted(request) = store + .admit_owner_request(&host, &owner, operator, 1, request_id) + .await + .expect("admit owner request") + else { + panic!("expected accepted request") + }; + assert_eq!(request.stage, DeletionStage::Submitted); + assert_eq!( + db.transfer_ownership(community, &new_owner, &owner) + .await + .expect("fenced transfer"), + TransferResult::DeletionPending + ); + + let aborted = store + .abort( + request_id, + "recovery-operator", + "owner preparation cannot continue", + ) + .await + .expect("privileged abort at the reversible submitted boundary"); + assert_eq!(aborted.stage, DeletionStage::Aborted); + assert_eq!(aborted.aborted_by.as_deref(), Some("recovery-operator")); + + // Abort reverses deletion intent, not the owner's archive decision. + let (deletion_state, archived_at): (String, Option>) = + sqlx::query_as("SELECT deletion_state, archived_at FROM communities WHERE id = $1") + .bind(community.as_uuid()) + .fetch_one(&db.pool) + .await + .expect("community lifecycle after abort"); + assert_eq!(deletion_state, "active"); + assert!( + archived_at.is_some(), + "abort must not unarchive the community on the owner's behalf" + ); + + assert!( + db.list_communities_owned_by(&owner) + .await + .expect("owner list after abort") + .iter() + .any(|row| row.id == community), + "aborting the request must restore the owner's actionable archived row" + ); + let UnarchiveCommunityResult::Unarchived(restored) = db + .unarchive_community_owned_by(&host, &owner) + .await + .expect("unarchive after abort") + else { + panic!("aborting the request must restore owner-authorized unarchive") + }; + assert_eq!(restored.id, community); + assert_eq!( + db.transfer_ownership(community, &new_owner, &owner) + .await + .expect("transfer after abort"), + TransferResult::Transferred { + previous_owner: Some(owner.clone()), + } + ); + } + + /// The reversible boundary extends through `fenced`. From `drained` + /// onward, destruction may have begun, so abort must stay closed. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn privileged_abort_spans_only_the_reversible_pre_destruction_boundary() { + let (db, store) = store().await; + let (request, _) = inventoried_request(&db, &store).await; + assert_eq!(request.stage, DeletionStage::Inventoried); + let aborted = store + .abort(request.id, "recovery-operator", "inventory needs recovery") + .await + .expect("privileged abort at the inventoried boundary"); + assert_eq!(aborted.stage, DeletionStage::Aborted); + + for irreversible in [ + DeletionStage::Drained, + DeletionStage::BindingsRemoved, + DeletionStage::PostgresPurged, + DeletionStage::CachePurged, + DeletionStage::LogicallyVerified, + DeletionStage::RetentionPending, + ] { + let (later, _) = inventoried_request(&db, &store).await; + sqlx::query("UPDATE community_deletion_requests SET stage = $2 WHERE id = $1") + .bind(later.id) + .bind(irreversible.to_string()) + .execute(&db.pool) + .await + .expect("advance stage"); + let error = store + .abort(later.id, "recovery-operator", "too late") + .await + .expect_err("abort must stay closed after destruction begins"); + assert!( + error.to_string().contains("cannot be aborted"), + "unexpected error at {irreversible}: {error}" + ); + assert_eq!( + store.get(later.id).await.expect("unchanged request").stage, + irreversible, + "a refused abort must not move the request" + ); + } + } + #[tokio::test] #[ignore = "requires Postgres"] async fn approval_boundary_blocks_claim_until_exact_inventory_is_approved() { diff --git a/crates/buzz-db/src/store/relay_members.rs b/crates/buzz-db/src/store/relay_members.rs index ecde3924fe..20309022cb 100644 --- a/crates/buzz-db/src/store/relay_members.rs +++ b/crates/buzz-db/src/store/relay_members.rs @@ -366,8 +366,10 @@ pub async fn update_relay_member_role( /// Ensures the configured owner pubkey holds the `"owner"` role *in /// `community`*, and demotes any other owners in that community to `"admin"`. /// This handles owner rotation: if `RELAY_OWNER_PUBKEY` changes, the old owner -/// is automatically demoted. Scoped to one community — an owner of community A -/// is never bootstrapped into community B. +/// is automatically demoted only while the community is active and has no +/// durable deletion intent. Scoped to one community — an owner of community A +/// is never bootstrapped into community B. Initial insertion and exact-owner +/// convergence remain allowed because neither rotates existing ownership. /// /// Runs in a single transaction. Safe to call at every startup — idempotent. /// @@ -383,13 +385,93 @@ pub async fn bootstrap_owner( community: CommunityId, owner_pubkey: &str, ) -> Result<()> { - bootstrap_owner_with_operation( + match bootstrap_owner_with_operation( pool, community, owner_pubkey, observability::WriterOperation::Bootstrap, ) - .await + .await? + { + ProvisionOwnerResult::Applied => Ok(()), + ProvisionOwnerResult::LifecycleConflict => Err(DbError::AccessDenied( + "community lifecycle freezes owner rotation".to_string(), + )), + ProvisionOwnerResult::DeletionPending => Err(DbError::AccessDenied( + "community deletion is pending".to_string(), + )), + } +} + +#[derive(Clone, Copy)] +enum OwnerMutationMode { + Transfer, + Converge, +} + +enum OwnerMutationAdmission { + Allowed(Vec), + NotFound, + LifecycleConflict, + DeletionPending, +} + +async fn lock_owner_mutation_admission( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community: CommunityId, + proposed_owner: &str, + mode: OwnerMutationMode, +) -> Result { + crate::deletion::lock_community_deletion_shared(tx, community).await?; + let target = sqlx::query( + "SELECT archived_at, deletion_state, deleted_at FROM communities \ + WHERE id = $1 FOR UPDATE", + ) + .bind(community.as_uuid()) + .fetch_optional(&mut **tx) + .await?; + let Some(target) = target else { + return Ok(OwnerMutationAdmission::NotFound); + }; + let existing_owners: Vec = sqlx::query_scalar( + "SELECT pubkey FROM relay_members \ + WHERE community_id = $1 AND role = 'owner' \ + ORDER BY pubkey FOR UPDATE", + ) + .bind(community.as_uuid()) + .fetch_all(&mut **tx) + .await?; + + let changes_existing_owner = match mode { + OwnerMutationMode::Transfer => true, + OwnerMutationMode::Converge => { + !(existing_owners.is_empty() + || existing_owners.len() == 1 && existing_owners[0] == proposed_owner) + } + }; + if !changes_existing_owner { + return Ok(OwnerMutationAdmission::Allowed(existing_owners)); + } + + let deletion_pending: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM community_deletion_requests \ + WHERE community_id = $1 AND stage <> 'aborted')", + ) + .bind(community.as_uuid()) + .fetch_one(&mut **tx) + .await?; + if deletion_pending { + return Ok(OwnerMutationAdmission::DeletionPending); + } + + let archived_at: Option> = target.try_get("archived_at")?; + let deletion_state: String = target.try_get("deletion_state")?; + let deleted_at: Option> = target.try_get("deleted_at")?; + if archived_at.is_some() || deletion_state != "active" || deleted_at.is_some() { + return Ok(OwnerMutationAdmission::LifecycleConflict); + } + + Ok(OwnerMutationAdmission::Allowed(existing_owners)) } async fn bootstrap_owner_with_operation( @@ -397,11 +479,25 @@ async fn bootstrap_owner_with_operation( community: CommunityId, owner_pubkey: &str, operation: observability::WriterOperation, -) -> Result<()> { +) -> Result { let pubkey = owner_pubkey.to_ascii_lowercase(); let connection = observability::acquire_writer(pool, operation).await?; let mut tx = sqlx::Transaction::begin(connection, None).await?; + match lock_owner_mutation_admission(&mut tx, community, &pubkey, OwnerMutationMode::Converge) + .await? + { + OwnerMutationAdmission::Allowed(_) => {} + OwnerMutationAdmission::NotFound | OwnerMutationAdmission::LifecycleConflict => { + tx.rollback().await?; + return Ok(ProvisionOwnerResult::LifecycleConflict); + } + OwnerMutationAdmission::DeletionPending => { + tx.rollback().await?; + return Ok(ProvisionOwnerResult::DeletionPending); + } + } + // 1. Upsert the configured owner for this community. sqlx::query( "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ @@ -424,7 +520,7 @@ async fn bootstrap_owner_with_operation( .await?; tx.commit().await?; - Ok(()) + Ok(ProvisionOwnerResult::Applied) } /// The result of a transfer-ownership attempt. @@ -444,12 +540,27 @@ pub enum TransferResult { /// concurrent transfer or owner rotation has already changed ownership. /// The caller must NOT retry blindly — re-read ownership and re-evaluate. OwnerConflict, + /// The community is archived, quiescing, or deleted; ownership is frozen. + LifecycleConflict, + /// Durable deletion intent exists and wins over ownership mutation. + DeletionPending, /// The transferee already owns the maximum number of communities. /// Enforced atomically inside the transfer transaction so concurrent /// transfers to the same recipient cannot both pass the limit. LimitReached, } +/// Result of converging an owner through deployment-root provisioning. +#[derive(Debug, PartialEq, Eq)] +pub enum ProvisionOwnerResult { + /// The initial owner was inserted or existing ownership converged. + Applied, + /// The community lifecycle freezes ownership mutation. + LifecycleConflict, + /// Durable deletion intent exists and wins over owner convergence. + DeletionPending, +} + /// Default maximum number of communities a single pubkey can own. Enforced at /// the relay layer — the authoritative layer — so that concurrent transfers or /// transfer-vs-create races cannot both pass a preflight count. @@ -500,13 +611,17 @@ pub fn owner_count_advisory_lock_key(pubkey_hex: &str) -> i64 { /// so that concurrent transfers to the same recipient serialize. The same /// lock key is also used by `Db::create_community_with_owner` to prevent /// transfer-vs-create races. -/// 2. Locks the current owner row `FOR UPDATE` and verifies +/// 2. Locks the community row and rejects archive/non-active lifecycle or any +/// non-aborted deletion request. +/// Owner-deletion admission takes the same row lock, so whichever operation +/// commits first makes the other re-evaluate and conflict. +/// 3. Locks the current owner row `FOR UPDATE` and verifies /// `expected_owner_pubkey` matches. This prevents a stale-owner race where /// a delayed/retried request overwrites a completed transfer. -/// 3. Enforces the [`MAX_COMMUNITIES_PER_OWNER`] limit on the transferee by +/// 4. Enforces the [`MAX_COMMUNITIES_PER_OWNER`] limit on the transferee by /// counting owned communities inside the same transaction. -/// 4. Upserts `new_owner_pubkey` as `owner` (insert or promote). -/// 5. Demotes every other owner in this community to `member` — **not** +/// 5. Upserts `new_owner_pubkey` as `owner` (insert or promote). +/// 6. Demotes every other owner in this community to `member` — **not** /// `admin`, per product decision: the former owner retains no management /// capabilities. /// @@ -533,18 +648,28 @@ pub async fn transfer_ownership( ) .await?; - // 2. Lock the current owner row FOR UPDATE and verify the expected owner. - // FOR UPDATE prevents the stale-owner race: a concurrent transfer that - // already changed the owner will block on this lock until our txn - // completes (or vice versa), and the expected_owner check will fail. - let existing_owners: Vec = sqlx::query_scalar( - "SELECT pubkey FROM relay_members \ - WHERE community_id = $1 AND role = 'owner' \ - FOR UPDATE", + let existing_owners = match lock_owner_mutation_admission( + &mut tx, + community, + &pubkey, + OwnerMutationMode::Transfer, ) - .bind(community.as_uuid()) - .fetch_all(&mut *tx) - .await?; + .await? + { + OwnerMutationAdmission::Allowed(existing_owners) => existing_owners, + OwnerMutationAdmission::NotFound => { + tx.rollback().await?; + return Ok(TransferResult::NoOwner); + } + OwnerMutationAdmission::LifecycleConflict => { + tx.rollback().await?; + return Ok(TransferResult::LifecycleConflict); + } + OwnerMutationAdmission::DeletionPending => { + tx.rollback().await?; + return Ok(TransferResult::DeletionPending); + } + }; if existing_owners.is_empty() { tx.rollback().await?; @@ -570,7 +695,7 @@ pub async fn transfer_ownership( existing_owners.iter().find(|p| **p != pubkey).cloned() }; - // 3. Enforce the transferee's community ownership limit inside the same + // 4. Enforce the transferee's community ownership limit inside the same // transaction that holds the advisory lock. This is the authoritative // check — kgoose's preflight count is advisory only. let owned_count: i64 = sqlx::query_scalar( @@ -585,7 +710,7 @@ pub async fn transfer_ownership( return Ok(TransferResult::LimitReached); } - // 4. Upsert the new owner. + // 5. Upsert the new owner. sqlx::query( "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ VALUES ($1, $2, 'owner', NULL) \ @@ -596,7 +721,7 @@ pub async fn transfer_ownership( .execute(&mut *tx) .await?; - // 5. Demote all other owners to member (not admin). + // 6. Demote all other owners to member (not admin). sqlx::query( "UPDATE relay_members SET role = 'member', updated_at = now() \ WHERE community_id = $1 AND role = 'owner' AND pubkey <> $2", @@ -798,7 +923,11 @@ impl Db { /// Ensure an owner during operator-driven community provisioning. #[datastore_span(name = "provision_owner", system = "postgresql")] - pub async fn provision_owner(&self, community: CommunityId, owner_pubkey: &str) -> Result<()> { + pub async fn provision_owner( + &self, + community: CommunityId, + owner_pubkey: &str, + ) -> Result { bootstrap_owner_with_operation( &self.pool, community, @@ -1341,6 +1470,50 @@ mod postgres_tests { assert_role(&pool, community, &owner, "owner").await; } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn transfer_ownership_rejects_archived_community_without_membership_change() { + let pool = setup_pool().await; + let (community, owner) = owned_community(&pool).await; + let replacement = test_pubkey(); + sqlx::query("UPDATE communities SET archived_at = now() WHERE id = $1") + .bind(community.as_uuid()) + .execute(&pool) + .await + .expect("archive community"); + + let result = transfer_ownership(&pool, community, &replacement, &owner) + .await + .expect("transfer archived community"); + + assert_eq!(result, TransferResult::LifecycleConflict); + assert_role(&pool, community, &owner, "owner").await; + assert!( + get_relay_member(&pool, community, &replacement) + .await + .expect("get replacement") + .is_none(), + "archived transfer must not add the replacement owner" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn provision_owner_still_bootstraps_initial_owner() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let owner = test_pubkey(); + let db = Db::from_pool(pool.clone()); + + assert_eq!( + db.provision_owner(community, &owner) + .await + .expect("provision initial owner"), + ProvisionOwnerResult::Applied + ); + assert_role(&pool, community, &owner, "owner").await; + } + /// Transferring a community with no owner row returns `NoOwner`. #[tokio::test] #[ignore = "requires Postgres"] diff --git a/crates/buzz-deletion/src/lib.rs b/crates/buzz-deletion/src/lib.rs index 714cf7eaff..2814c48691 100644 --- a/crates/buzz-deletion/src/lib.rs +++ b/crates/buzz-deletion/src/lib.rs @@ -227,7 +227,11 @@ pub enum Command { /// Canonical community host. Defaults to RELAY_URL's authority. #[arg(long)] host: Option, - /// Operator identity recorded on the request. + /// Identity recorded on the request. + /// + /// Also the convergence key for an existing `submitted` request: to + /// take over an owner-origin request, pass its owner pubkey. The + /// operator's own pubkey conflicts with it instead of converging. #[arg(long)] requested_by: String, /// Optional reason for the request. diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 1c912e599e..64095decc1 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -293,7 +293,11 @@ pub async fn provision_community( Err(msg) if msg.starts_with("actor not authorized") => { Err(api_error(StatusCode::FORBIDDEN, &msg)) } - Err(msg) if msg == "community already exists" || msg.starts_with("limit_reached:") => { + Err(msg) + if msg == "community already exists" + || msg.starts_with("limit_reached:") + || msg.starts_with("owner_conflict:") => + { Err(api_error(StatusCode::CONFLICT, &msg)) } Err(msg) @@ -314,6 +318,15 @@ pub struct ArchiveCommunityRequest { owner_pubkey: String, } +/// Authenticated owner intent mediated by a trusted deployment operator. +#[derive(Debug, Deserialize)] +pub struct DeleteCommunityRequest { + host: String, + owner_pubkey: String, + request_id: Uuid, + acknowledgement_version: i32, +} + /// Idempotently archive a community owned by the asserted end-user identity. pub async fn archive_community( State(state): State>, @@ -398,12 +411,23 @@ pub async fn unarchive_community( "invalid owner_pubkey: expected 64-char hex pubkey", ) })?; - let record = state + let result = state .db .unarchive_community_owned_by(&normalized_host, &owner) .await - .map_err(|e| internal_error(&format!("unarchive community: {e}")))? - .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "community not found"))?; + .map_err(|e| internal_error(&format!("unarchive community: {e}")))?; + let record = match result { + buzz_db::UnarchiveCommunityResult::Unarchived(record) => record, + buzz_db::UnarchiveCommunityResult::DeletionPending => { + return Err(api_error( + StatusCode::CONFLICT, + "community deletion is pending", + )); + } + buzz_db::UnarchiveCommunityResult::NotFound => { + return Err(api_error(StatusCode::NOT_FOUND, "community not found")); + } + }; tracing::info!(community = %record.id, host = %record.host, "community unarchived"); Ok(Json(serde_json::json!({ "community_id": record.id.to_string(), @@ -413,6 +437,113 @@ pub async fn unarchive_community( }))) } +/// Persist authenticated owner deletion intent without executing deletion work. +/// +/// `POST /operator/communities/delete`, NIP-98 signed by a pubkey in +/// `RELAY_OPERATOR_PUBKEYS`, body: +/// +/// ```json +/// { +/// "host": "archived.communities.example", +/// "owner_pubkey": "<64-char hex>", +/// "request_id": "", +/// "acknowledgement_version": 1 +/// } +/// ``` +/// +/// The request UUID is the correlation/idempotency key. Acceptance is a fast +/// PostgreSQL-only transaction and returns `202`; inventory, approval, +/// quiescing, object-store access, and executor work remain asynchronous. +/// +/// Owner consent is asserted by the operator, not proven to the relay. The +/// operator authenticates the owner and collects the acknowledgement upstream; +/// this request carries only the operator's NIP-98 signature. Authorization is +/// therefore operator authority plus "the asserted pubkey is still the owner". +/// `owner_pubkey` and `acknowledgement_version` are recorded as provenance for +/// that upstream ceremony, not verified as cryptographic owner consent. +pub async fn delete_community( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + const PATH: &str = "/operator/communities/delete"; + let operator = + authorize_operator_request(&state, &headers, "POST", PATH, None, Some(&body)).await?; + let request: DeleteCommunityRequest = serde_json::from_slice(&body).map_err(|e| { + api_error( + StatusCode::BAD_REQUEST, + &format!("invalid delete-community JSON: {e}"), + ) + })?; + let normalized_host = normalize_candidate_host(&request.host) + .map_err(|msg| api_error(StatusCode::BAD_REQUEST, &msg))?; + let deployment_host = buzz_core::tenant::relay_url_authority(&state.config.relay_url); + if normalized_host == deployment_host { + return Err(api_error( + StatusCode::CONFLICT, + "the deployment community cannot be deleted", + )); + } + let owner = validate_pubkey_hex(&request.owner_pubkey).ok_or_else(|| { + api_error( + StatusCode::BAD_REQUEST, + "invalid owner_pubkey: expected 64-char hex pubkey", + ) + })?; + let operator = operator.to_hex(); + let admission = state + .db + .deletion_store() + .admit_owner_request( + &normalized_host, + &owner, + &operator, + request.acknowledgement_version, + request.request_id, + ) + .await + .map_err(|error| internal_error(&format!("admit owner deletion request: {error}")))?; + let accepted = match admission { + buzz_db::deletion::OwnerDeletionAdmission::Accepted(request) => request, + buzz_db::deletion::OwnerDeletionAdmission::NotFoundOrNotOwner => { + return Err(api_error(StatusCode::NOT_FOUND, "community not found")); + } + buzz_db::deletion::OwnerDeletionAdmission::NotArchived => { + return Err(api_error( + StatusCode::CONFLICT, + "community must be archived before deletion", + )); + } + buzz_db::deletion::OwnerDeletionAdmission::LifecycleConflict => { + return Err(api_error( + StatusCode::CONFLICT, + "community deletion lifecycle is already active", + )); + } + buzz_db::deletion::OwnerDeletionAdmission::RequestConflict => { + return Err(api_error( + StatusCode::CONFLICT, + "deletion request conflicts with existing intent", + )); + } + buzz_db::deletion::OwnerDeletionAdmission::UnsupportedAcknowledgementVersion => { + return Err(api_error( + StatusCode::BAD_REQUEST, + "unsupported acknowledgement_version", + )); + } + }; + Ok(( + StatusCode::ACCEPTED, + Json(serde_json::json!({ + "request_id": accepted.id, + "community_id": accepted.community_id.to_string(), + "host": accepted.community_host, + "status": accepted.stage.to_string(), + })), + )) +} + /// List communities where a pubkey currently holds the `owner` role. pub async fn list_owned_communities( State(state): State>, @@ -535,6 +666,18 @@ pub async fn transfer_community( "owner_conflict: the current owner no longer matches expected_owner_pubkey", )); } + buzz_db::relay_members::TransferResult::LifecycleConflict => { + return Err(api_error( + StatusCode::CONFLICT, + "community must be active to transfer ownership", + )); + } + buzz_db::relay_members::TransferResult::DeletionPending => { + return Err(api_error( + StatusCode::CONFLICT, + "community deletion is pending", + )); + } buzz_db::relay_members::TransferResult::LimitReached => { return Err(api_error( StatusCode::CONFLICT, @@ -798,6 +941,40 @@ mod postgres_tests { .expect("response") } + /// Send a delete-community request with a caller-controlled `Authorization` + /// header so signature-binding failures can be exercised directly. + async fn raw_owner_delete( + state: Arc, + auth: Option, + extra_header: Option<(&str, String)>, + body: String, + ) -> axum::response::Response { + let mut request = Request::builder() + .method("POST") + .uri("/operator/communities/delete") + .header(header::HOST, INGRESS_HOST) + .header(header::CONTENT_TYPE, "application/json"); + if let Some(auth) = auth { + request = request.header(header::AUTHORIZATION, auth); + } + if let Some((name, value)) = extra_header { + request = request.header(name, value); + } + build_router(state) + .oneshot(request.body(Body::from(body)).expect("request")) + .await + .expect("response") + } + + /// The endpoint must not have persisted intent under `request_id`. + async fn assert_no_persisted_request(state: &AppState, request_id: Uuid, case: &str) { + let found = state.db.deletion_store().get(request_id).await; + assert!( + matches!(found, Err(buzz_db::DbError::NotFound(_))), + "{case}: rejected request must not persist deletion intent" + ); + } + async fn provision_community( state: Arc, operator: &Keys, @@ -813,6 +990,29 @@ mod postgres_tests { signed_operator_request(state, operator, "POST", "/operator/communities", Some(body)).await } + async fn archive_for_owner_deletion(state: &AppState, host: &str, owner: &Keys) { + state + .db + .archive_community_owned_by( + host, + &owner.public_key().to_hex(), + &buzz_core::tenant::relay_url_authority(&state.config.relay_url), + ) + .await + .expect("archive community") + .expect("owned community"); + } + + fn owner_delete_body(host: &str, owner: &Keys, request_id: Uuid) -> String { + serde_json::json!({ + "host": host, + "owner_pubkey": owner.public_key().to_hex(), + "request_id": request_id, + "acknowledgement_version": 1, + }) + .to_string() + } + fn is_member_tag(tag: &Tag, pubkey: &str, role: &str) -> bool { let values = tag.as_slice(); values.first().is_some_and(|value| value == "member") @@ -882,6 +1082,500 @@ mod postgres_tests { assert_eq!(response.status(), StatusCode::FORBIDDEN); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn owner_delete_endpoint_accepts_archived_owner_intent_without_running_inventory() { + let operator = Keys::generate(); + let owner = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + let host = format!("community-{}.example", Uuid::new_v4().simple()); + assert_eq!( + provision_community(Arc::clone(&state), &operator, &host, &owner) + .await + .status(), + StatusCode::OK + ); + archive_for_owner_deletion(&state, &host, &owner).await; + let request_id = Uuid::new_v4(); + let response = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some(owner_delete_body(&host, &owner, request_id)), + ) + .await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + let json = read_json(response).await; + assert_eq!(json["request_id"], request_id.to_string()); + assert_eq!(json["status"], "submitted"); + let request = state + .db + .deletion_store() + .get(request_id) + .await + .expect("persisted deletion request"); + assert_eq!(request.stage, buzz_db::deletion::DeletionStage::Submitted); + assert!(request.inventory_manifest.is_none()); + assert!(request.inventory_digest.is_none()); + assert_eq!( + request.mediating_operator_pubkey.as_deref(), + Some(operator.public_key().to_hex().as_str()) + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn owner_delete_endpoint_rejects_protected_host_and_malformed_or_mismatched_target() { + let operator = Keys::generate(); + let owner = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + let protected_host = buzz_core::tenant::relay_url_authority(&state.config.relay_url); + let protected = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some(owner_delete_body(&protected_host, &owner, Uuid::new_v4())), + ) + .await; + assert_eq!(protected.status(), StatusCode::CONFLICT); + + // Each malformed case keeps every unrelated field valid so it reaches + // the guard under test instead of tripping an earlier one, and none of + // them may leave durable intent behind. + let valid_owner = owner.public_key().to_hex(); + let reachable_host = format!("community-{}.example", Uuid::new_v4().simple()); + + let bad_host_id = Uuid::new_v4(); + let bad_host = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some( + serde_json::json!({ + "host": "https://not-an-authority.example/path", + "owner_pubkey": valid_owner, + "request_id": bad_host_id, + "acknowledgement_version": 1, + }) + .to_string(), + ), + ) + .await; + assert_eq!(bad_host.status(), StatusCode::BAD_REQUEST); + assert_no_persisted_request(&state, bad_host_id, "unnormalizable host").await; + + let bad_pubkey_id = Uuid::new_v4(); + let bad_pubkey = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some( + serde_json::json!({ + "host": reachable_host, + "owner_pubkey": "not-a-pubkey", + "request_id": bad_pubkey_id, + "acknowledgement_version": 1, + }) + .to_string(), + ), + ) + .await; + assert_eq!(bad_pubkey.status(), StatusCode::BAD_REQUEST); + let bad_pubkey_error = read_json(bad_pubkey).await; + assert!( + bad_pubkey_error + .get("error") + .and_then(Value::as_str) + .unwrap_or_default() + .contains("owner_pubkey"), + "invalid owner_pubkey must reach the pubkey guard: {bad_pubkey_error:?}" + ); + assert_no_persisted_request(&state, bad_pubkey_id, "invalid owner_pubkey").await; + + let bad_version_id = Uuid::new_v4(); + let bad_version = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some( + serde_json::json!({ + "host": reachable_host, + "owner_pubkey": valid_owner, + "request_id": bad_version_id, + "acknowledgement_version": 2, + }) + .to_string(), + ), + ) + .await; + assert_eq!(bad_version.status(), StatusCode::BAD_REQUEST); + assert_no_persisted_request(&state, bad_version_id, "unsupported acknowledgement").await; + + let unknown_host_id = Uuid::new_v4(); + let unknown_host = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some( + serde_json::json!({ + "host": reachable_host, + "owner_pubkey": valid_owner, + "request_id": unknown_host_id, + "acknowledgement_version": 1, + }) + .to_string(), + ), + ) + .await; + assert_eq!(unknown_host.status(), StatusCode::NOT_FOUND); + assert_no_persisted_request(&state, unknown_host_id, "unprovisioned host").await; + + let bad_uuid = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some( + serde_json::json!({ + "host": reachable_host, + "owner_pubkey": valid_owner, + "request_id": "not-a-uuid", + "acknowledgement_version": 1, + }) + .to_string(), + ), + ) + .await; + assert_eq!(bad_uuid.status(), StatusCode::BAD_REQUEST); + + let host = format!("community-{}.example", Uuid::new_v4().simple()); + assert_eq!( + provision_community(Arc::clone(&state), &operator, &host, &owner) + .await + .status(), + StatusCode::OK + ); + archive_for_owner_deletion(&state, &host, &owner).await; + let request_id = Uuid::new_v4(); + let first = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some(owner_delete_body(&host, &owner, request_id)), + ) + .await; + assert_eq!(first.status(), StatusCode::ACCEPTED); + let other_host = format!("community-{}.example", Uuid::new_v4().simple()); + assert_eq!( + provision_community(Arc::clone(&state), &operator, &other_host, &owner) + .await + .status(), + StatusCode::OK + ); + archive_for_owner_deletion(&state, &other_host, &owner).await; + let mismatched = signed_operator_request( + state, + &operator, + "POST", + "/operator/communities/delete", + Some(owner_delete_body(&other_host, &owner, request_id)), + ) + .await; + assert_eq!(mismatched.status(), StatusCode::CONFLICT); + } + + /// Every signature-binding failure mode on the owner-delete endpoint. + /// + /// The endpoint mediates an irreversible request, so a caller that cannot + /// prove operator authority over this exact method, URL, and body must be + /// refused without leaving durable intent behind. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn owner_delete_endpoint_requires_operator_bound_nip98_signature() { + let operator = Keys::generate(); + let outsider = Keys::generate(); + let owner = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + let host = format!("community-{}.example", Uuid::new_v4().simple()); + assert_eq!( + provision_community(Arc::clone(&state), &operator, &host, &owner) + .await + .status(), + StatusCode::OK + ); + archive_for_owner_deletion(&state, &host, &owner).await; + + let delete_url = format!("http://{INGRESS_HOST}/operator/communities/delete"); + + // 1. No Authorization header at all. + let unsigned_id = Uuid::new_v4(); + let unsigned = raw_owner_delete( + Arc::clone(&state), + None, + None, + owner_delete_body(&host, &owner, unsigned_id), + ) + .await; + assert_eq!(unsigned.status(), StatusCode::UNAUTHORIZED); + assert_no_persisted_request(&state, unsigned_id, "unsigned").await; + + // 2. X-Pubkey only: the dev fallback is disabled for operator endpoints. + let x_pubkey_id = Uuid::new_v4(); + let x_pubkey_only = raw_owner_delete( + Arc::clone(&state), + None, + Some(("x-pubkey", operator.public_key().to_hex())), + owner_delete_body(&host, &owner, x_pubkey_id), + ) + .await; + assert_eq!(x_pubkey_only.status(), StatusCode::UNAUTHORIZED); + assert_no_persisted_request(&state, x_pubkey_id, "X-Pubkey only").await; + + // 3. Valid NIP-98 from a key that is not an allowlisted operator. + let outsider_id = Uuid::new_v4(); + let outsider_body = owner_delete_body(&host, &owner, outsider_id); + let outsider_response = raw_owner_delete( + Arc::clone(&state), + Some(nip98_auth_header( + &outsider, + &delete_url, + "POST", + Some(outsider_body.as_bytes()), + )), + None, + outsider_body, + ) + .await; + assert_eq!(outsider_response.status(), StatusCode::FORBIDDEN); + assert_no_persisted_request(&state, outsider_id, "non-operator signer").await; + + // 4. Operator signature that omits the payload tag. + let no_payload_id = Uuid::new_v4(); + let no_payload = raw_owner_delete( + Arc::clone(&state), + Some(nip98_auth_header_without_payload( + &operator, + &delete_url, + "POST", + )), + None, + owner_delete_body(&host, &owner, no_payload_id), + ) + .await; + assert_eq!(no_payload.status(), StatusCode::UNAUTHORIZED); + assert_no_persisted_request(&state, no_payload_id, "missing payload tag").await; + + // 5. Payload tag bound to a different body than the one sent. + let signed_id = Uuid::new_v4(); + let tampered_id = Uuid::new_v4(); + let signed_body = owner_delete_body(&host, &owner, signed_id); + let tampered = raw_owner_delete( + Arc::clone(&state), + Some(nip98_auth_header( + &operator, + &delete_url, + "POST", + Some(signed_body.as_bytes()), + )), + None, + owner_delete_body(&host, &owner, tampered_id), + ) + .await; + assert_eq!(tampered.status(), StatusCode::UNAUTHORIZED); + assert_no_persisted_request(&state, tampered_id, "tampered payload").await; + assert_no_persisted_request(&state, signed_id, "tampered payload (signed id)").await; + + // 6. Signature bound to a different operator URL. + let wrong_url_id = Uuid::new_v4(); + let wrong_url_body = owner_delete_body(&host, &owner, wrong_url_id); + let wrong_url = raw_owner_delete( + Arc::clone(&state), + Some(nip98_auth_header( + &operator, + &format!("http://{INGRESS_HOST}/operator/communities"), + "POST", + Some(wrong_url_body.as_bytes()), + )), + None, + wrong_url_body, + ) + .await; + assert_eq!(wrong_url.status(), StatusCode::UNAUTHORIZED); + assert_no_persisted_request(&state, wrong_url_id, "wrong signed URL").await; + + // 7. Signature bound to a different method. + let wrong_method_id = Uuid::new_v4(); + let wrong_method_body = owner_delete_body(&host, &owner, wrong_method_id); + let wrong_method = raw_owner_delete( + Arc::clone(&state), + Some(nip98_auth_header( + &operator, + &delete_url, + "GET", + Some(wrong_method_body.as_bytes()), + )), + None, + wrong_method_body, + ) + .await; + assert_eq!(wrong_method.status(), StatusCode::UNAUTHORIZED); + assert_no_persisted_request(&state, wrong_method_id, "wrong signed method").await; + + // Falsifiability: the same shape, correctly bound, is accepted. + let accepted_id = Uuid::new_v4(); + let accepted = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some(owner_delete_body(&host, &owner, accepted_id)), + ) + .await; + assert_eq!(accepted.status(), StatusCode::ACCEPTED); + } + + /// The request UUID is the correlation identity, so a replay that changes + /// any bound field is a conflict rather than a second interpretation. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn owner_delete_replay_rejects_any_changed_field_without_mutating_intent() { + let operator = Keys::generate(); + let owner = Keys::generate(); + let other_owner = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + let host = format!("community-{}.example", Uuid::new_v4().simple()); + assert_eq!( + provision_community(Arc::clone(&state), &operator, &host, &owner) + .await + .status(), + StatusCode::OK + ); + archive_for_owner_deletion(&state, &host, &owner).await; + + let other_host = format!("community-{}.example", Uuid::new_v4().simple()); + assert_eq!( + provision_community(Arc::clone(&state), &operator, &other_host, &owner) + .await + .status(), + StatusCode::OK + ); + archive_for_owner_deletion(&state, &other_host, &owner).await; + + let request_id = Uuid::new_v4(); + assert_eq!( + signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some(owner_delete_body(&host, &owner, request_id)), + ) + .await + .status(), + StatusCode::ACCEPTED + ); + let admitted = state + .db + .deletion_store() + .get(request_id) + .await + .expect("admitted request"); + + // Exactly one field differs per case; the rest replay verbatim. + let owner_hex = owner.public_key().to_hex(); + let cases: Vec<(&str, StatusCode, String)> = vec![ + ( + "changed owner_pubkey", + StatusCode::CONFLICT, + serde_json::json!({ + "host": host, + "owner_pubkey": other_owner.public_key().to_hex(), + "request_id": request_id, + "acknowledgement_version": 1, + }) + .to_string(), + ), + ( + "changed host", + StatusCode::CONFLICT, + serde_json::json!({ + "host": other_host, + "owner_pubkey": owner_hex, + "request_id": request_id, + "acknowledgement_version": 1, + }) + .to_string(), + ), + ( + "changed acknowledgement_version", + StatusCode::BAD_REQUEST, + serde_json::json!({ + "host": host, + "owner_pubkey": owner_hex, + "request_id": request_id, + "acknowledgement_version": 2, + }) + .to_string(), + ), + ]; + for (case, expected, body) in cases { + let response = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some(body), + ) + .await; + assert_eq!(response.status(), expected, "{case}"); + let current = state + .db + .deletion_store() + .get(request_id) + .await + .expect("request still readable"); + assert_eq!(current.community_id, admitted.community_id, "{case}"); + assert_eq!(current.community_host, admitted.community_host, "{case}"); + assert_eq!(current.owner_pubkey, admitted.owner_pubkey, "{case}"); + assert_eq!( + current.acknowledgement_version, admitted.acknowledgement_version, + "{case}" + ); + assert_eq!(current.stage, admitted.stage, "{case}"); + } + + // An identical replay still converges on the same request. + let converged = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some(owner_delete_body(&host, &owner, request_id)), + ) + .await; + assert_eq!(converged.status(), StatusCode::ACCEPTED); + assert_eq!( + read_json(converged).await["request_id"], + request_id.to_string() + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn post_operator_body_requires_payload_tag() { @@ -1378,6 +2072,68 @@ mod postgres_tests { assert_snapshot_roles(&state, community.id, &[(&owner_hex, "owner")]).await; } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn archived_legacy_owner_convergence_returns_conflict_without_membership_change() { + let operator = Keys::generate(); + let owner = Keys::generate(); + let replacement = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + let host = format!("community-{}.example", Uuid::new_v4().simple()); + assert_eq!( + provision_community(state.clone(), &operator, &host, &owner) + .await + .status(), + StatusCode::OK + ); + let community = state + .db + .lookup_community_by_host(&host) + .await + .expect("lookup community") + .expect("community exists"); + archive_for_owner_deletion(&state, &host, &owner).await; + + let body = serde_json::json!({ + "host": host, + "initial_owner_pubkey": replacement.public_key().to_hex(), + "create_only": false, + }) + .to_string(); + let response = signed_operator_request( + state.clone(), + &operator, + "POST", + "/operator/communities", + Some(body), + ) + .await; + + assert_eq!(response.status(), StatusCode::CONFLICT); + assert_eq!( + read_json(response).await["error"], + "owner_conflict: community must be active to rotate ownership" + ); + assert_eq!( + state + .db + .get_relay_member(community.id, &owner.public_key().to_hex()) + .await + .expect("get owner") + .expect("owner exists") + .role, + "owner" + ); + assert!(state + .db + .get_relay_member(community.id, &replacement.public_key().to_hex()) + .await + .expect("get replacement") + .is_none()); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn fresh_host_at_owner_limit_returns_limit_reached_conflict() { diff --git a/crates/buzz-relay/src/handlers/community_provisioning.rs b/crates/buzz-relay/src/handlers/community_provisioning.rs index 229b5f3716..1b13e6d921 100644 --- a/crates/buzz-relay/src/handlers/community_provisioning.rs +++ b/crates/buzz-relay/src/handlers/community_provisioning.rs @@ -21,9 +21,9 @@ //! { "host": "acme.communities.buzz.xyz", "initial_owner_pubkey": "" } //! ``` //! -//! `initial_owner_pubkey` is optional. When present for an existing community, -//! it rotates that community owner through the same bootstrap path used by -//! `RELAY_OWNER_PUBKEY`; relay operators are deployment-root authorities. +//! `initial_owner_pubkey` is optional. Existing-community convergence may +//! rotate ownership only while the community is active and has no durable +//! deletion intent. Archive freezes ownership. use std::sync::Arc; @@ -238,14 +238,9 @@ async fn publish_membership_snapshot_if_required( /// signer here for the deployment-level `RELAY_OPERATOR_PUBKEYS` allowlist. /// /// Idempotency and owner semantics: the request is idempotent on the host row -/// (re-sending it never duplicates a community). When `initial_owner_pubkey` is -/// present, the owner is (re)bootstrapped via [`buzz_db::Db::bootstrap_owner`] -/// even if the community already existed — any previous owner is demoted to -/// admin, exactly like rotating `RELAY_OWNER_PUBKEY` for the deployment -/// community. This makes a retry after a partial failure (row created, owner -/// bootstrap crashed) converge, at the cost that an operator-signed request can -/// rotate an existing community's owner. The operator allowlist is therefore -/// documented as deployment-root authority, not create-only authority. +/// (re-sending it never duplicates a community). Initial owner bootstrap still +/// converges after a partial create, but rotating an existing owner requires an +/// active, non-archived community with no non-aborted deletion request. pub async fn provision_community( state: &Arc, operator_pubkey: &nostr::PublicKey, @@ -325,11 +320,22 @@ pub async fn provision_community( .map_err(|e| format!("failed to create community: {e}"))?; if let Some(owner_hex) = &initial_owner { - state + match state .db .provision_owner(record.id, owner_hex) .await - .map_err(|e| format!("community provisioned but owner bootstrap failed: {e}"))?; + .map_err(|e| format!("community provisioned but owner bootstrap failed: {e}"))? + { + buzz_db::relay_members::ProvisionOwnerResult::Applied => {} + buzz_db::relay_members::ProvisionOwnerResult::LifecycleConflict => { + return Err( + "owner_conflict: community must be active to rotate ownership".to_string(), + ); + } + buzz_db::relay_members::ProvisionOwnerResult::DeletionPending => { + return Err("owner_conflict: community deletion is pending".to_string()); + } + } publish_membership_snapshot_if_required(state, record.id, &record.host).await; } diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 3e96c02c13..9993ea4c8a 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -330,6 +330,10 @@ pub fn build_router(state: Arc) -> Router { "/operator/communities/unarchive", post(api::operator::unarchive_community), ) + .route( + "/operator/communities/delete", + post(api::operator::delete_community), + ) .route( "/operator/communities/availability", get(api::operator::community_availability), diff --git a/migrations/0051_owner_community_deletion_admission.sql b/migrations/0051_owner_community_deletion_admission.sql new file mode 100644 index 0000000000..25002b32da --- /dev/null +++ b/migrations/0051_owner_community_deletion_admission.sql @@ -0,0 +1,81 @@ +-- Structured provenance for owner-origin whole-community deletion requests. +-- +-- The existing request UUID is the stable correlation/idempotency identity. +-- Owner admission supplies that UUID instead of creating a second identity +-- column, while these bounded columns distinguish owner intent from the +-- deployment operator that mediated it. +-- +-- The owner branch rejects a NULL in every provenance column before testing +-- its shape. A bare `col ~ '...'` on a NULL column yields NULL, and a CHECK is +-- satisfied by NULL, so `FALSE OR NULL` would silently admit an owner-origin +-- row with no owner key, no mediating operator, or no acknowledgement version. +-- +-- The null rejection is spelled `NOT (col IS NULL)` rather than +-- `col IS NOT NULL`: pgschema drops a named CHECK whose body contains +-- `IS NOT NULL` and still exits 0, which would leave the desired-state +-- bootstrap in `schema/schema.sql` silently unguarded while the migration path +-- stayed correct. `store::deletion::owner_provenance_contract` asserts both +-- schema sources against one case table so that divergence fails loudly. +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE community_deletion_requests + ADD COLUMN request_origin TEXT NOT NULL DEFAULT 'operator' + CHECK (request_origin IN ('operator', 'owner')), + ADD COLUMN owner_pubkey TEXT, + ADD COLUMN mediating_operator_pubkey TEXT, + ADD COLUMN acknowledgement_version INTEGER, + ADD CONSTRAINT community_deletion_owner_provenance CHECK ( + (request_origin = 'operator' + AND owner_pubkey IS NULL + AND mediating_operator_pubkey IS NULL + AND acknowledgement_version IS NULL) + OR + (request_origin = 'owner' + AND NOT (owner_pubkey IS NULL) + AND NOT (mediating_operator_pubkey IS NULL) + AND NOT (acknowledgement_version IS NULL) + AND owner_pubkey ~ '^[0-9a-f]{64}$' + AND mediating_operator_pubkey ~ '^[0-9a-f]{64}$' + AND acknowledgement_version BETWEEN 1 AND 32767 + AND requested_by = owner_pubkey) + ); + +CREATE OR REPLACE FUNCTION prevent_community_deletion_request_retargeting() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.community_host IS DISTINCT FROM OLD.community_host + THEN + RAISE EXCEPTION 'community deletion target identity is immutable' + USING ERRCODE = 'integrity_constraint_violation'; + END IF; + IF NEW.request_origin IS DISTINCT FROM OLD.request_origin + OR NEW.owner_pubkey IS DISTINCT FROM OLD.owner_pubkey + OR NEW.mediating_operator_pubkey IS DISTINCT FROM OLD.mediating_operator_pubkey + OR NEW.acknowledgement_version IS DISTINCT FROM OLD.acknowledgement_version + THEN + RAISE EXCEPTION 'community deletion request provenance is immutable' + USING ERRCODE = 'integrity_constraint_violation'; + END IF; + IF OLD.inventory_frozen_at IS NOT NULL AND ( + NEW.schema_manifest IS DISTINCT FROM OLD.schema_manifest + OR NEW.storage_manifest IS DISTINCT FROM OLD.storage_manifest + OR NEW.inventory_manifest IS DISTINCT FROM OLD.inventory_manifest + OR NEW.inventory_digest IS DISTINCT FROM OLD.inventory_digest + OR NEW.inventory_frozen_at IS DISTINCT FROM OLD.inventory_frozen_at + ) THEN + RAISE EXCEPTION 'frozen community deletion inventory is immutable' + USING ERRCODE = 'integrity_constraint_violation'; + END IF; + IF OLD.destructive_storage_frozen_at IS NOT NULL AND ( + NEW.destructive_storage_manifest IS DISTINCT FROM OLD.destructive_storage_manifest + OR NEW.destructive_storage_frozen_at IS DISTINCT FROM OLD.destructive_storage_frozen_at + ) THEN + RAISE EXCEPTION 'frozen destructive storage manifest is immutable' + USING ERRCODE = 'integrity_constraint_violation'; + END IF; + RETURN NEW; +END; +$$; diff --git a/schema/schema.sql b/schema/schema.sql index 1ba4bb0399..fdd382f463 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -1232,6 +1232,11 @@ CREATE TABLE community_deletion_requests ( 'logically_verified', 'retention_pending', 'aborted' )), requested_by TEXT NOT NULL, + request_origin TEXT NOT NULL DEFAULT 'operator' + CHECK (request_origin IN ('operator', 'owner')), + owner_pubkey TEXT, + mediating_operator_pubkey TEXT, + acknowledgement_version INTEGER, reason TEXT, schema_manifest JSONB, storage_manifest JSONB, @@ -1268,6 +1273,21 @@ CREATE TABLE community_deletion_requests ( CHECK ((aborted_at IS NULL) = (aborted_by IS NULL)), CHECK ((aborted_at IS NULL) = (abort_reason IS NULL)), CHECK ((inventory_frozen_at IS NULL) = (inventory_digest IS NULL)), + CONSTRAINT community_deletion_owner_provenance CHECK ( + (request_origin = 'operator' + AND owner_pubkey IS NULL + AND mediating_operator_pubkey IS NULL + AND acknowledgement_version IS NULL) + OR + (request_origin = 'owner' + AND NOT (owner_pubkey IS NULL) + AND NOT (mediating_operator_pubkey IS NULL) + AND NOT (acknowledgement_version IS NULL) + AND owner_pubkey ~ '^[0-9a-f]{64}$' + AND mediating_operator_pubkey ~ '^[0-9a-f]{64}$' + AND acknowledgement_version BETWEEN 1 AND 32767 + AND requested_by = owner_pubkey) + ), UNIQUE (id, community_id, inventory_digest) ); CREATE UNIQUE INDEX community_deletion_requests_active_community @@ -1293,7 +1313,7 @@ CREATE TABLE community_deletion_approvals ( ON DELETE RESTRICT ); -CREATE FUNCTION prevent_community_deletion_request_retargeting() +CREATE OR REPLACE FUNCTION prevent_community_deletion_request_retargeting() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -1304,6 +1324,14 @@ BEGIN RAISE EXCEPTION 'community deletion target identity is immutable' USING ERRCODE = 'integrity_constraint_violation'; END IF; + IF NEW.request_origin IS DISTINCT FROM OLD.request_origin + OR NEW.owner_pubkey IS DISTINCT FROM OLD.owner_pubkey + OR NEW.mediating_operator_pubkey IS DISTINCT FROM OLD.mediating_operator_pubkey + OR NEW.acknowledgement_version IS DISTINCT FROM OLD.acknowledgement_version + THEN + RAISE EXCEPTION 'community deletion request provenance is immutable' + USING ERRCODE = 'integrity_constraint_violation'; + END IF; IF OLD.inventory_frozen_at IS NOT NULL AND ( NEW.schema_manifest IS DISTINCT FROM OLD.schema_manifest OR NEW.storage_manifest IS DISTINCT FROM OLD.storage_manifest From 548718e042decbc059fd7ebfb9b9ad2459ac258d Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Mon, 28 Sep 2026 12:46:14 -0700 Subject: [PATCH 09/12] test(desktop): scope video menu e2e probes to emitted messages (#7953) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes the `Desktop Smoke E2E (4)` failure on `main`. The test at `desktop/tests/e2e/video-attachment.spec.ts` ("right-click menus expose distinct selectors for links, relay video, and off-relay video") fails 3/3 attempts on main and on every PR branch since ~Sept 25. Because "Desktop" is a required check, that blocks merges. **Cause:** the relay-video and off-relay-video probes picked their player with `getByTestId("video-player").last()`. When mock messages share a second, timeline ordering can put a different video last. The locator then resolves to the wrong player, and the "Download video" menu detaches before the click, so the test times out. **Fix:** scope each probe to the `data-message-id` of the message `emitVideoMessage` just returned. Test-only change, 16 lines. ## Attribution This is a cherry-pick of `dfd5db4e0` by @tornquist, originally part of #7827 (the deletion-drain cron, which is stacked on another feature branch). It's pulled out on its own so main can go green without waiting on that stack. Authorship is preserved. ## Testing - `pnpm build:e2e && playwright test tests/e2e/video-attachment.spec.ts --project=smoke`: 13/13 passed locally, including the target test. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: tornquist Signed-off-by: murderbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz> Co-authored-by: tornquist Co-authored-by: Codex --- desktop/tests/e2e/video-attachment.spec.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/desktop/tests/e2e/video-attachment.spec.ts b/desktop/tests/e2e/video-attachment.spec.ts index 2c86f5a9bd..060d356d28 100644 --- a/desktop/tests/e2e/video-attachment.spec.ts +++ b/desktop/tests/e2e/video-attachment.spec.ts @@ -1513,12 +1513,14 @@ test("right-click menus expose distinct selectors for links, relay video, and of // ── Relay video menu: Download video + Copy link, appearing only once the // relay origin resolves (the reactivity fix) ───────────────────────────── - await emitVideoMessage(page, { + const relayMessage = (await emitVideoMessage(page, { url: MENU_RELAY_VIDEO_URL, sha: MENU_RELAY_VIDEO_SHA, filename: "relay-clip.mp4", - }); - const relayPlayer = page.getByTestId("video-player").last(); + })) as { id: string }; + const relayPlayer = page + .locator(`[data-message-id="${relayMessage.id}"]`) + .getByTestId("video-player"); await expect(relayPlayer).toBeVisible(); // Right-click the player surface. `force` skips the actionability guard: the // Play-button overlay sits above the video, but the contextmenu event still @@ -1559,12 +1561,14 @@ test("right-click menus expose distinct selectors for links, relay video, and of await expect(page.locator("[data-video-context-menu]")).toHaveCount(0); // ── Off-relay video control: renders and offers Copy link, never Download ─ - await emitVideoMessage(page, { + const offRelayMessage = (await emitVideoMessage(page, { url: MENU_OFF_RELAY_VIDEO_URL, sha: MENU_OFF_RELAY_VIDEO_SHA, filename: "external-clip.mp4", - }); - const offRelayPlayer = page.getByTestId("video-player").last(); + })) as { id: string }; + const offRelayPlayer = page + .locator(`[data-message-id="${offRelayMessage.id}"]`) + .getByTestId("video-player"); await expect(offRelayPlayer).toBeVisible(); await offRelayPlayer.click({ button: "right", force: true }); From cc36d25bec2d34340ab7aace50110f51162edd7a Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Mon, 28 Sep 2026 12:46:43 -0700 Subject: [PATCH 10/12] Add native HPKE encryption for nsec backups (#7849) ## Summary Add a Rust API that encrypts an nsec into a backup envelope using RFC 9180 Base mode: DHKEM(P-256, HKDF-SHA256), HKDF-SHA256, and AES-256-GCM. --------- Signed-off-by: jm Signed-off-by: Jordan Mecom Co-authored-by: Codex --- desktop/src-tauri/src/hpke_key_backup.rs | 393 +++++++++++++++++ .../src-tauri/src/hpke_key_backup_tests.rs | 408 ++++++++++++++++++ desktop/src-tauri/src/lib.rs | 2 + .../src/testdata/hpke_nsec_backup_v1.json | 22 + 4 files changed, 825 insertions(+) create mode 100644 desktop/src-tauri/src/hpke_key_backup.rs create mode 100644 desktop/src-tauri/src/hpke_key_backup_tests.rs create mode 100644 desktop/src-tauri/src/testdata/hpke_nsec_backup_v1.json diff --git a/desktop/src-tauri/src/hpke_key_backup.rs b/desktop/src-tauri/src/hpke_key_backup.rs new file mode 100644 index 0000000000..24894ab1b2 --- /dev/null +++ b/desktop/src-tauri/src/hpke_key_backup.rs @@ -0,0 +1,393 @@ +//! HPKE encryption for a future enterprise identity-backup service. +//! +//! This module only creates the opaque client envelope. Recipient enrollment, +//! upload, authorization, storage, and recovery are deliberately outside its +//! scope. In particular, this is a native Rust API and is not registered as a +//! Tauri command: renderer-controlled recipient keys would turn encryption into +//! an nsec export path. +//! +//! This is a pure sealing component, not an active [`crate::app_state::AppState`] +//! backup operation. A future enrollment/upload path must serialize identity +//! selection and sealing with `AppState::identity_mutation`, obtain the live +//! identity through `AppState::signing_keys()`, and bind its public key to the +//! authenticated enrollment before upload. This API alone does not protect +//! against identity lock, loss, or concurrent rotation, and its tests do not +//! claim that runtime integration. + +use std::fmt; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use nostr::{PublicKey, SecretKey}; +use rustls::crypto::{ + aws_lc_rs::hpke::DH_KEM_P256_HKDF_SHA256_AES_256, + hpke::{Hpke, HpkePublicKey}, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use zeroize::Zeroizing; + +/// HPKE `info` bytes for every v1 nsec-backup context. +pub const HPKE_INFO_V1: &[u8] = b"buzz/nsec-backup/v1"; + +/// Version of the envelope and associated-data contract. +pub const ENVELOPE_VERSION_V1: u8 = 1; + +/// RFC 9180 KEM ID for DHKEM(P-256, HKDF-SHA256). +pub const KEM_ID_P256_HKDF_SHA256: u16 = 0x0010; + +/// RFC 9180 KDF ID for HKDF-SHA256. +pub const KDF_ID_HKDF_SHA256: u16 = 0x0001; + +/// RFC 9180 AEAD ID for AES-256-GCM. +pub const AEAD_ID_AES_256_GCM: u16 = 0x0002; + +/// Encoded length of an uncompressed SEC1 P-256 public key. +pub const P256_PUBLIC_KEY_LEN: usize = 65; + +/// Encoded length of a v1 ciphertext: 32 secret bytes plus a 16-byte GCM tag. +pub const CIPHERTEXT_LEN: usize = 48; + +const AAD_DOMAIN_V1: &[u8] = b"buzz/nsec-backup/aad/v1"; +const MAX_CONTEXT_FIELD_LEN: usize = 255; +const P256_PUBLIC_KEY_BASE64URL_LEN: usize = 87; +const CIPHERTEXT_BASE64URL_LEN: usize = 64; + +/// Errors produced while validating or sealing an HPKE nsec-backup envelope. +/// +/// Error values never contain secret-key bytes or plaintext. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HpkeBackupError { + /// An enrollment or envelope field did not satisfy the v1 wire contract. + InvalidField { + /// Name of the invalid field. + field: &'static str, + /// Non-sensitive validation failure. + reason: &'static str, + }, + /// The recipient key is not an uncompressed P-256 SEC1 point. + InvalidRecipientKey(&'static str), + /// An envelope is inconsistent with the v1 wire contract. + InvalidEnvelope(&'static str), + /// HPKE rejected the recipient key or failed to seal the plaintext. + EncryptionFailed, +} + +impl fmt::Display for HpkeBackupError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidField { field, reason } => { + write!(formatter, "invalid backup {field}: {reason}") + } + Self::InvalidRecipientKey(reason) => { + write!(formatter, "invalid HPKE recipient public key: {reason}") + } + Self::InvalidEnvelope(reason) => write!(formatter, "invalid backup envelope: {reason}"), + Self::EncryptionFailed => { + formatter.write_str("HPKE backup encryption failed; recipient key may be invalid") + } + } + } +} + +impl std::error::Error for HpkeBackupError {} + +/// Fixed algorithm identifiers carried by a v1 envelope. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct HpkeSuiteIds { + kem_id: u16, + kdf_id: u16, + aead_id: u16, +} + +impl HpkeSuiteIds { + const V1: Self = Self { + kem_id: KEM_ID_P256_HKDF_SHA256, + kdf_id: KDF_ID_HKDF_SHA256, + aead_id: AEAD_ID_AES_256_GCM, + }; + + fn is_v1(self) -> bool { + self == Self::V1 + } +} + +/// Enrollment values authenticated into one backup envelope. +#[derive(Clone, Debug)] +pub struct HpkeBackupEnrollment { + recipient_key_id: String, + service_namespace: String, + owner_id: String, + backup_id: Uuid, + recipient_public_key: [u8; P256_PUBLIC_KEY_LEN], +} + +impl HpkeBackupEnrollment { + /// Validate enrollment metadata supplied by a trusted native integration. + /// + /// Identifier strings are encoded as UTF-8 bytes without normalization in + /// AAD. Each must be 1 through 255 bytes and must not contain control + /// characters. The backup ID is encoded as its 16 raw UUID bytes. + pub fn new( + recipient_key_id: impl Into, + service_namespace: impl Into, + owner_id: impl Into, + backup_id: Uuid, + recipient_public_key_sec1: &[u8], + ) -> Result { + let recipient_key_id = recipient_key_id.into(); + let service_namespace = service_namespace.into(); + let owner_id = owner_id.into(); + validate_context_field("recipient key ID", &recipient_key_id)?; + validate_context_field("service namespace", &service_namespace)?; + validate_context_field("owner ID", &owner_id)?; + + let recipient_public_key: [u8; P256_PUBLIC_KEY_LEN] = recipient_public_key_sec1 + .try_into() + .map_err(|_| HpkeBackupError::InvalidRecipientKey("expected exactly 65 SEC1 bytes"))?; + if recipient_public_key[0] != 0x04 { + return Err(HpkeBackupError::InvalidRecipientKey( + "expected uncompressed SEC1 prefix 0x04", + )); + } + + Ok(Self { + recipient_key_id, + service_namespace, + owner_id, + backup_id, + recipient_public_key, + }) + } +} + +/// Opaque v1 backup envelope produced by [`seal_nostr_secret`]. +/// +/// JSON serialization is the wire representation. Binary HPKE values use +/// URL-safe base64 with no padding. This type contains no plaintext or raw nsec +/// material. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct HpkeBackupEnvelope { + version: u8, + suite: HpkeSuiteIds, + recipient_key_id: String, + service_namespace: String, + owner_id: String, + backup_id: String, + nostr_pubkey: String, + enc: String, + ciphertext: String, +} + +impl HpkeBackupEnvelope { + /// Reconstruct the v1 associated-data bytes from envelope metadata. + /// + /// This validates fixed suite/version values and canonical metadata + /// encodings before returning bytes. Receivers must separately validate + /// `enc` and ciphertext through the byte accessors before HPKE open. + pub fn associated_data(&self) -> Result, HpkeBackupError> { + if self.version != ENVELOPE_VERSION_V1 { + return Err(HpkeBackupError::InvalidEnvelope("unsupported version")); + } + if !self.suite.is_v1() { + return Err(HpkeBackupError::InvalidEnvelope("unsupported HPKE suite")); + } + validate_context_field("recipient key ID", &self.recipient_key_id)?; + validate_context_field("service namespace", &self.service_namespace)?; + validate_context_field("owner ID", &self.owner_id)?; + + let backup_id = Uuid::parse_str(&self.backup_id) + .map_err(|_| HpkeBackupError::InvalidEnvelope("backup ID is not a UUID"))?; + if backup_id.hyphenated().to_string() != self.backup_id { + return Err(HpkeBackupError::InvalidEnvelope( + "backup ID is not canonical lowercase hyphenated UUID", + )); + } + + let nostr_pubkey = PublicKey::from_hex(&self.nostr_pubkey) + .map_err(|_| HpkeBackupError::InvalidEnvelope("invalid Nostr public key"))?; + if nostr_pubkey.to_hex() != self.nostr_pubkey { + return Err(HpkeBackupError::InvalidEnvelope( + "Nostr public key is not canonical lowercase hex", + )); + } + + Ok(build_aad( + &self.recipient_key_id, + &self.service_namespace, + &self.owner_id, + backup_id.as_bytes(), + &nostr_pubkey.to_bytes(), + )) + } + + /// Decode and validate the RFC 9180 encapsulated P-256 public-key shape. + /// + /// This enforces the 65-byte uncompressed SEC1 representation and its + /// `0x04` prefix. The HPKE provider validates P-256 curve membership when + /// a recipient uses these bytes to open the envelope. + pub fn encapsulated_key_bytes(&self) -> Result, HpkeBackupError> { + let decoded = decode_canonical_base64( + &self.enc, + P256_PUBLIC_KEY_BASE64URL_LEN, + P256_PUBLIC_KEY_LEN, + "enc", + )?; + if decoded.first() != Some(&0x04) { + return Err(HpkeBackupError::InvalidField { + field: "enc", + reason: "expected uncompressed SEC1 prefix 0x04", + }); + } + Ok(decoded) + } + + /// Decode and validate the 32-byte-secret AES-256-GCM ciphertext. + pub fn ciphertext_bytes(&self) -> Result, HpkeBackupError> { + decode_canonical_base64( + &self.ciphertext, + CIPHERTEXT_BASE64URL_LEN, + CIPHERTEXT_LEN, + "ciphertext", + ) + } +} + +/// Seal one native Nostr secret key to a trusted HPKE backup enrollment. +/// +/// The function derives the Nostr public key from `secret_key` and encrypts its +/// raw 32-byte secret representation in RFC 9180 Base mode. `rustls` creates a +/// fresh ephemeral P-256 key for every call. HPKE Base mode does not +/// authenticate the sender; future upload authorization or a separate signature +/// must provide that property. +pub fn seal_nostr_secret( + secret_key: &SecretKey, + enrollment: &HpkeBackupEnrollment, +) -> Result { + let nostr_pubkey = PublicKey::from(secret_key.x_only_public_key(nostr::SECP256K1).0).to_hex(); + let mut envelope = HpkeBackupEnvelope { + version: ENVELOPE_VERSION_V1, + suite: HpkeSuiteIds::V1, + recipient_key_id: enrollment.recipient_key_id.clone(), + service_namespace: enrollment.service_namespace.clone(), + owner_id: enrollment.owner_id.clone(), + backup_id: enrollment.backup_id.hyphenated().to_string(), + nostr_pubkey, + enc: String::new(), + ciphertext: String::new(), + }; + let aad = envelope.associated_data()?; + // This clears our buffer on drop, but not the HPKE provider's internal + // plaintext copy, which can be freed without zeroization. + let plaintext = Zeroizing::new(secret_key.to_secret_bytes()); + + let recipient = HpkePublicKey(enrollment.recipient_public_key.to_vec()); + let (enc, ciphertext) = DH_KEM_P256_HKDF_SHA256_AES_256 + .seal(HPKE_INFO_V1, &aad, plaintext.as_ref(), &recipient) + .map_err(|_| HpkeBackupError::EncryptionFailed)?; + + if enc.0.len() != P256_PUBLIC_KEY_LEN || ciphertext.len() != CIPHERTEXT_LEN { + return Err(HpkeBackupError::EncryptionFailed); + } + envelope.enc = URL_SAFE_NO_PAD.encode(enc.0); + envelope.ciphertext = URL_SAFE_NO_PAD.encode(ciphertext); + Ok(envelope) +} + +fn validate_context_field(field: &'static str, value: &str) -> Result<(), HpkeBackupError> { + if value.is_empty() { + return Err(HpkeBackupError::InvalidField { + field, + reason: "must not be empty", + }); + } + if value.len() > MAX_CONTEXT_FIELD_LEN { + return Err(HpkeBackupError::InvalidField { + field, + reason: "must be at most 255 UTF-8 bytes", + }); + } + if value.chars().any(char::is_control) { + return Err(HpkeBackupError::InvalidField { + field, + reason: "must not contain control characters", + }); + } + Ok(()) +} + +fn build_aad( + recipient_key_id: &str, + service_namespace: &str, + owner_id: &str, + backup_id: &[u8; 16], + nostr_pubkey: &[u8; 32], +) -> Vec { + let mut aad = Vec::with_capacity( + AAD_DOMAIN_V1.len() + + 7 + + 6 + + recipient_key_id.len() + + service_namespace.len() + + owner_id.len() + + backup_id.len() + + nostr_pubkey.len(), + ); + aad.extend_from_slice(AAD_DOMAIN_V1); + aad.push(ENVELOPE_VERSION_V1); + aad.extend_from_slice(&KEM_ID_P256_HKDF_SHA256.to_be_bytes()); + aad.extend_from_slice(&KDF_ID_HKDF_SHA256.to_be_bytes()); + aad.extend_from_slice(&AEAD_ID_AES_256_GCM.to_be_bytes()); + push_framed(&mut aad, recipient_key_id.as_bytes()); + push_framed(&mut aad, service_namespace.as_bytes()); + push_framed(&mut aad, owner_id.as_bytes()); + aad.extend_from_slice(backup_id); + aad.extend_from_slice(nostr_pubkey); + aad +} + +fn push_framed(output: &mut Vec, value: &[u8]) { + // All callers first apply MAX_CONTEXT_FIELD_LEN, which is below u16::MAX. + output.extend_from_slice(&(value.len() as u16).to_be_bytes()); + output.extend_from_slice(value); +} + +fn decode_canonical_base64( + value: &str, + expected_encoded_len: usize, + expected_decoded_len: usize, + field: &'static str, +) -> Result, HpkeBackupError> { + // `str::len` is the UTF-8 byte length. Check it before decoding so an + // untrusted JSON string cannot cause an allocation proportional to input. + if value.len() != expected_encoded_len { + return Err(HpkeBackupError::InvalidField { + field, + reason: "unexpected encoded length", + }); + } + let decoded = URL_SAFE_NO_PAD + .decode(value) + .map_err(|_| HpkeBackupError::InvalidField { + field, + reason: "invalid base64url", + })?; + if decoded.len() != expected_decoded_len { + return Err(HpkeBackupError::InvalidField { + field, + reason: "unexpected decoded length", + }); + } + if URL_SAFE_NO_PAD.encode(&decoded) != value { + return Err(HpkeBackupError::InvalidField { + field, + reason: "base64url is not canonical and unpadded", + }); + } + Ok(decoded) +} + +#[cfg(test)] +#[path = "hpke_key_backup_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/hpke_key_backup_tests.rs b/desktop/src-tauri/src/hpke_key_backup_tests.rs new file mode 100644 index 0000000000..3b15964ba5 --- /dev/null +++ b/desktop/src-tauri/src/hpke_key_backup_tests.rs @@ -0,0 +1,408 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use nostr::{Keys, SecretKey, ToBech32}; +use rustls::crypto::hpke::{EncapsulatedSecret, Hpke, HpkePrivateKey}; + +use super::*; + +fn test_secret() -> SecretKey { + SecretKey::from_slice(&[0x42; 32]).expect("fixed test secret is valid") +} + +fn test_backup_id() -> Uuid { + Uuid::parse_str("018f6f4e-83d1-7a4e-8c8e-4f914f7d31a2").expect("fixed UUID is valid") +} + +fn test_recipient() -> (rustls::crypto::hpke::HpkePublicKey, HpkePrivateKey) { + DH_KEM_P256_HKDF_SHA256_AES_256 + .generate_key_pair() + .expect("generate test HPKE recipient") +} + +fn enrollment(public_key: &[u8]) -> HpkeBackupEnrollment { + HpkeBackupEnrollment::new( + "backup-key-2026-09", + "backup/staging", + "subject:01J8A4M7Q1G4X8S8M51FS7K2HX", + test_backup_id(), + public_key, + ) + .expect("valid test enrollment") +} + +fn open_envelope( + envelope: &HpkeBackupEnvelope, + private_key: &HpkePrivateKey, +) -> Result, rustls::Error> { + let aad = envelope + .associated_data() + .map_err(|error| rustls::Error::General(error.to_string()))?; + let enc = envelope + .encapsulated_key_bytes() + .map_err(|error| rustls::Error::General(error.to_string()))?; + let ciphertext = envelope + .ciphertext_bytes() + .map_err(|error| rustls::Error::General(error.to_string()))?; + DH_KEM_P256_HKDF_SHA256_AES_256.open( + &EncapsulatedSecret(enc), + HPKE_INFO_V1, + &aad, + &ciphertext, + private_key, + ) +} + +#[test] +fn receiver_recovers_exact_secret_and_matching_pubkey() { + let (public_key, private_key) = test_recipient(); + let secret = test_secret(); + let envelope = seal_nostr_secret(&secret, &enrollment(&public_key.0)).expect("seal backup"); + + let opened = open_envelope(&envelope, &private_key).expect("open backup"); + assert_eq!(opened, secret.as_secret_bytes()); + assert_eq!( + envelope.nostr_pubkey, + Keys::new(secret).public_key().to_hex() + ); + assert_eq!(envelope.enc.len(), 87); + assert_eq!(envelope.ciphertext.len(), 64); + assert_eq!(envelope.encapsulated_key_bytes().unwrap().len(), 65); + assert_eq!(envelope.ciphertext_bytes().unwrap().len(), 48); +} + +#[test] +fn every_backup_uses_a_fresh_hpke_context() { + let (public_key, private_key) = test_recipient(); + let enrollment = enrollment(&public_key.0); + let secret = test_secret(); + + let first = seal_nostr_secret(&secret, &enrollment).expect("first seal"); + let second = seal_nostr_secret(&secret, &enrollment).expect("second seal"); + + assert_ne!(first.enc, second.enc); + assert_ne!(first.ciphertext, second.ciphertext); + assert_eq!(open_envelope(&first, &private_key).unwrap(), [0x42; 32]); + assert_eq!(open_envelope(&second, &private_key).unwrap(), [0x42; 32]); +} + +#[test] +fn malformed_and_invalid_recipient_keys_are_rejected() { + let short = [0x04; 64]; + assert!(matches!( + HpkeBackupEnrollment::new("key", "service", "owner", test_backup_id(), &short), + Err(HpkeBackupError::InvalidRecipientKey(_)) + )); + + let compressed = [0x02; 33]; + assert!( + HpkeBackupEnrollment::new("key", "service", "owner", test_backup_id(), &compressed) + .is_err() + ); + + let mut wrong_prefix = [0u8; P256_PUBLIC_KEY_LEN]; + wrong_prefix[0] = 0x03; + assert!( + HpkeBackupEnrollment::new("key", "service", "owner", test_backup_id(), &wrong_prefix) + .is_err() + ); + + let mut off_curve = [0u8; P256_PUBLIC_KEY_LEN]; + off_curve[0] = 0x04; + let enrollment = + HpkeBackupEnrollment::new("key", "service", "owner", test_backup_id(), &off_curve) + .expect("structurally encoded point reaches provider validation"); + assert_eq!( + seal_nostr_secret(&test_secret(), &enrollment), + Err(HpkeBackupError::EncryptionFailed) + ); +} + +#[test] +fn metadata_ciphertext_and_encapsulation_tampering_fail_closed() { + let (public_key, private_key) = test_recipient(); + let envelope = seal_nostr_secret(&test_secret(), &enrollment(&public_key.0)).unwrap(); + + let mut recipient_key_id_tampered = envelope.clone(); + recipient_key_id_tampered + .recipient_key_id + .push_str("-different"); + let mut namespace_tampered = envelope.clone(); + namespace_tampered.service_namespace.push_str("-different"); + let mut owner_tampered = envelope.clone(); + owner_tampered.owner_id.push_str("-different"); + let mut backup_id_tampered = envelope.clone(); + backup_id_tampered.backup_id = Uuid::new_v4().hyphenated().to_string(); + let mut pubkey_tampered = envelope.clone(); + pubkey_tampered.nostr_pubkey = Keys::generate().public_key().to_hex(); + for tampered in [ + recipient_key_id_tampered, + namespace_tampered, + owner_tampered, + backup_id_tampered, + pubkey_tampered, + ] { + assert!(open_envelope(&tampered, &private_key).is_err()); + } + + let aad = envelope.associated_data().unwrap(); + let enc = envelope.encapsulated_key_bytes().unwrap(); + let ciphertext = envelope.ciphertext_bytes().unwrap(); + assert!(DH_KEM_P256_HKDF_SHA256_AES_256 + .open( + &EncapsulatedSecret(enc), + b"buzz/nsec-backup/v2", + &aad, + &ciphertext, + &private_key, + ) + .is_err()); + + let mut ciphertext_tampered = envelope.clone(); + let mut ciphertext = ciphertext_tampered.ciphertext_bytes().unwrap(); + ciphertext[0] ^= 1; + ciphertext_tampered.ciphertext = URL_SAFE_NO_PAD.encode(ciphertext); + assert!(open_envelope(&ciphertext_tampered, &private_key).is_err()); + + let mut enc_tampered = envelope.clone(); + let mut enc = enc_tampered.encapsulated_key_bytes().unwrap(); + let last = enc.len() - 1; + enc[last] ^= 1; + enc_tampered.enc = URL_SAFE_NO_PAD.encode(enc); + assert!(open_envelope(&enc_tampered, &private_key).is_err()); +} + +#[test] +fn wrong_recipient_cannot_open() { + let (public_key, _) = test_recipient(); + let (_, wrong_private_key) = test_recipient(); + let envelope = seal_nostr_secret(&test_secret(), &enrollment(&public_key.0)).unwrap(); + assert!(open_envelope(&envelope, &wrong_private_key).is_err()); +} + +#[test] +fn enrollment_rejects_invalid_context_fields() { + let (public_key, _) = test_recipient(); + assert!( + HpkeBackupEnrollment::new("", "service", "owner", test_backup_id(), &public_key.0).is_err() + ); + assert!(HpkeBackupEnrollment::new( + "key", + "x".repeat(256), + "owner", + test_backup_id(), + &public_key.0 + ) + .is_err()); + assert!(HpkeBackupEnrollment::new( + "key", + "service", + "owner\nvalue", + test_backup_id(), + &public_key.0 + ) + .is_err()); +} + +#[test] +fn aad_length_framing_prevents_field_boundary_collisions() { + let (public_key, private_key) = test_recipient(); + let secret = test_secret(); + + for (left, right) in [ + (["ab", "c", "owner"], ["a", "bc", "owner"]), + (["key", "ab", "c"], ["key", "a", "bc"]), + ] { + assert_eq!(left.concat(), right.concat()); + let enrollment = + HpkeBackupEnrollment::new(left[0], left[1], left[2], test_backup_id(), &public_key.0) + .unwrap(); + let envelope = seal_nostr_secret(&secret, &enrollment).unwrap(); + assert_eq!( + open_envelope(&envelope, &private_key).unwrap(), + secret.as_secret_bytes() + ); + + let mut reassigned = envelope.clone(); + reassigned.recipient_key_id = right[0].into(); + reassigned.service_namespace = right[1].into(); + reassigned.owner_id = right[2].into(); + assert_ne!( + envelope.associated_data().unwrap(), + reassigned.associated_data().unwrap() + ); + assert!(open_envelope(&reassigned, &private_key).is_err()); + } +} + +#[test] +fn envelope_rejects_noncanonical_or_conflicting_metadata() { + let (public_key, _) = test_recipient(); + let envelope = seal_nostr_secret(&test_secret(), &enrollment(&public_key.0)).unwrap(); + + let mut wrong_version = envelope.clone(); + wrong_version.version = 2; + assert!(wrong_version.associated_data().is_err()); + + let mut wrong_suite = envelope.clone(); + wrong_suite.suite.aead_id = 1; + assert!(wrong_suite.associated_data().is_err()); + + let mut uppercase_pubkey = envelope.clone(); + uppercase_pubkey.nostr_pubkey.make_ascii_uppercase(); + assert!(uppercase_pubkey.associated_data().is_err()); + + let mut padded_base64 = envelope.clone(); + padded_base64.enc.push('='); + assert!(padded_base64.encapsulated_key_bytes().is_err()); +} + +#[test] +fn envelope_rejects_binary_fields_with_wrong_byte_lengths_before_decode() { + let (public_key, _) = test_recipient(); + let envelope = seal_nostr_secret(&test_secret(), &enrollment(&public_key.0)).unwrap(); + + let mut oversized_enc = envelope.clone(); + oversized_enc.enc.push_str(&"A".repeat(1_000_000)); + assert_eq!( + oversized_enc.encapsulated_key_bytes(), + Err(HpkeBackupError::InvalidField { + field: "enc", + reason: "unexpected encoded length", + }) + ); + + let mut short_enc = envelope.clone(); + short_enc.enc.pop(); + assert_eq!( + short_enc.encapsulated_key_bytes(), + Err(HpkeBackupError::InvalidField { + field: "enc", + reason: "unexpected encoded length", + }) + ); + + let mut multibyte_ciphertext = envelope.clone(); + multibyte_ciphertext.ciphertext = "é".repeat(envelope.ciphertext.len()); + assert_eq!( + multibyte_ciphertext.ciphertext_bytes(), + Err(HpkeBackupError::InvalidField { + field: "ciphertext", + reason: "unexpected encoded length", + }) + ); +} + +#[test] +fn envelope_rejects_malformed_base64_and_wrong_encapsulation_prefix() { + let (public_key, _) = test_recipient(); + let envelope = seal_nostr_secret(&test_secret(), &enrollment(&public_key.0)).unwrap(); + + let mut malformed_enc = envelope.clone(); + malformed_enc.enc.replace_range(..1, "!"); + assert_eq!( + malformed_enc.encapsulated_key_bytes(), + Err(HpkeBackupError::InvalidField { + field: "enc", + reason: "invalid base64url", + }) + ); + + let mut malformed_ciphertext = envelope.clone(); + malformed_ciphertext.ciphertext.replace_range(..1, "!"); + assert_eq!( + malformed_ciphertext.ciphertext_bytes(), + Err(HpkeBackupError::InvalidField { + field: "ciphertext", + reason: "invalid base64url", + }) + ); + + let mut wrong_prefix = envelope.clone(); + let mut enc = URL_SAFE_NO_PAD.decode(&wrong_prefix.enc).unwrap(); + enc[0] = 0x03; + wrong_prefix.enc = URL_SAFE_NO_PAD.encode(enc); + assert_eq!( + wrong_prefix.encapsulated_key_bytes(), + Err(HpkeBackupError::InvalidField { + field: "enc", + reason: "expected uncompressed SEC1 prefix 0x04", + }) + ); +} + +#[test] +fn serialized_envelope_contains_no_plaintext_or_nsec() { + let (public_key, _) = test_recipient(); + let secret = test_secret(); + let envelope = seal_nostr_secret(&secret, &enrollment(&public_key.0)).unwrap(); + let json = serde_json::to_string(&envelope).unwrap(); + let raw_hex = hex::encode(secret.as_secret_bytes()); + let nsec = secret.to_bech32().unwrap(); + + assert!(!json.contains(&raw_hex)); + assert!(!json.contains(&nsec)); + assert!(!json.contains("nsec1")); +} + +#[test] +fn official_rfc9180_exact_suite_vector_opens() { + // RFC 9180's JSON vector for the KEM/KDF/AEAD tuple used here. + // Source pinned by the RFC itself: + // https://github.com/cfrg/draft-irtf-cfrg-hpke/blob/ + // 5f503c564da00b0687b3de75f1dfbdfc4079ad31/test-vectors.json + let info = hex::decode("4f6465206f6e2061204772656369616e2055726e").unwrap(); + let aad = hex::decode("436f756e742d30").unwrap(); + let plaintext = + hex::decode("4265617574792069732074727574682c20747275746820626561757479").unwrap(); + let enc = hex::decode( + "04c06b4f6bebc7bb495cb797ab753f911aff80aefb86fd8b6fcc35525f3ab5f03e0b21bd31a86c6048af3cb2d98e0d3bf01da5cc4c39ff5370d331a4f1f7d5a4e0", + ) + .unwrap(); + let ciphertext = hex::decode( + "58c61a45059d0c5704560e9d88b564a8b63f1364b8d1fcb3c4c6ddc1d291742465e902cd216f8908da49f8f96f", + ) + .unwrap(); + let recipient_private = HpkePrivateKey::from( + hex::decode("317f915db7bc629c48fe765587897e01e282d3e8445f79f27f65d031a88082b2").unwrap(), + ); + + let opened = DH_KEM_P256_HKDF_SHA256_AES_256 + .open( + &EncapsulatedSecret(enc), + &info, + &aad, + &ciphertext, + &recipient_private, + ) + .expect("open official CFRG vector"); + assert_eq!(opened, plaintext); +} + +#[derive(serde::Deserialize)] +struct InteropFixture { + recipient_public_key_sec1_hex: String, + recipient_private_key_hex: String, + nostr_secret_key_hex: String, + aad_hex: String, + envelope: HpkeBackupEnvelope, +} + +#[test] +fn checked_in_v1_fixture_opens_and_reconstructs_aad() { + let fixture: InteropFixture = + serde_json::from_str(include_str!("testdata/hpke_nsec_backup_v1.json")) + .expect("parse checked-in interop fixture"); + let recipient_public = hex::decode(&fixture.recipient_public_key_sec1_hex).unwrap(); + let recipient_private = + HpkePrivateKey::from(hex::decode(&fixture.recipient_private_key_hex).unwrap()); + let secret = hex::decode(&fixture.nostr_secret_key_hex).unwrap(); + + assert_eq!(recipient_public.len(), P256_PUBLIC_KEY_LEN); + assert_eq!( + hex::encode(fixture.envelope.associated_data().unwrap()), + fixture.aad_hex + ); + assert_eq!( + open_envelope(&fixture.envelope, &recipient_private).unwrap(), + secret + ); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index dce4a05a81..a37d9c536a 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -10,6 +10,8 @@ mod deep_link; mod egress_guard; mod event_sync; mod events; +#[cfg_attr(not(test), allow(dead_code))] +mod hpke_key_backup; mod huddle; mod identity_storage; mod initial_window; diff --git a/desktop/src-tauri/src/testdata/hpke_nsec_backup_v1.json b/desktop/src-tauri/src/testdata/hpke_nsec_backup_v1.json new file mode 100644 index 0000000000..0bbecac656 --- /dev/null +++ b/desktop/src-tauri/src/testdata/hpke_nsec_backup_v1.json @@ -0,0 +1,22 @@ +{ + "description": "Buzz HPKE nsec backup v1 Kotlin interop fixture; keys are test-only", + "recipient_public_key_sec1_hex": "04c5b6519400186a8e10abbdc05d3e450c8612e5dba06927091527110324acfb0371d98d662395d872fae967fe75ef24db20a7c2c1af19c34422d94f629b1b1dad", + "recipient_private_key_hex": "7ad00e296b224923f735144f3cc7e49746c8e3ebf20b8002d0a73998467d8f78", + "nostr_secret_key_hex": "4242424242424242424242424242424242424242424242424242424242424242", + "aad_hex": "62757a7a2f6e7365632d6261636b75702f6161642f76310100100001000200126261636b75702d6b65792d323032362d3039000e6261636b75702f73746167696e6700227375626a6563743a30314a3841344d3751314734583853384d35314653374b324858018f6f4e83d17a4e8c8e4f914f7d31a224653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c", + "envelope": { + "version": 1, + "suite": { + "aead_id": 2, + "kdf_id": 1, + "kem_id": 16 + }, + "recipient_key_id": "backup-key-2026-09", + "service_namespace": "backup/staging", + "owner_id": "subject:01J8A4M7Q1G4X8S8M51FS7K2HX", + "backup_id": "018f6f4e-83d1-7a4e-8c8e-4f914f7d31a2", + "nostr_pubkey": "24653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c", + "enc": "BHCSkhHMDDKXgK5vWuP4Gw3NNaMMdrN-1OPUISh_jczrjoZK7HBmMkZE2iogEKle5syyp3-wgGo69cPeHVNEuWA", + "ciphertext": "tkBhVlv3rco5mZOIEbESB2Hx_etTIvWQHJJP7hCOIgEysJjwQqnq7iCC0A516Du8" + } +} From 9b1c4f98e2981d6d89fbfa3154b8ef0b3b6ad334 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 28 Sep 2026 16:36:40 -0400 Subject: [PATCH 11/12] =?UTF-8?q?=F0=9F=A4=96=20perf:=20bound=20long-threa?= =?UTF-8?q?d=20aux=20reads=20and=20make=20query=20deadlines=20terminal=20(?= =?UTF-8?q?#7854)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long threads (over ~100 replies) time out in desktop. The aux hop that loads reactions and other e-tag references builds its filter as one `tags @> …` term per id, joined with `OR`. Past about 100 ids the planner switches to walking each partition's primary key backward, expecting to stop early. When few rows actually match, it scans the whole community. With a dirty GIN pending list, a 188-reply thread never finishes. ### What changes - **Set-based e-tag reads (`buzz-db`).** The aux e-tag query now puts the id set behind a `MATERIALIZED` CTE and matches with `ANY`. Each e-tag read also runs under a transaction-local `statement_timeout` of 20 s. - **Terminal timeout contract (`buzz-relay`).** When a read hits the deadline, the client gets a terminal timeout (`CLOSED` for REQ, the timeout error for COUNT and search), not a retryable failure. - **Subscription lifecycle (`buzz-relay`).** The per-connection subscription map lock is now the lifecycle lock. Claims, replacement, client `CLOSE`, owner-checked timeout and search cleanup, revoke, and connection teardown all change the map, the registry, topic refcounts and terminal frames while holding it. As a result, a timed-out subscription's `CLOSED` goes out before any replacement with the same id can claim it, and claims made after teardown are refused. - **Dropped terminal frame disconnects (`buzz-relay`).** If the `CLOSED` for a retired subscription (owner-checked timeout or channel eviction) can't be queued, the relay cancels the connection instead of leaving the client holding a subscription that will never receive fan-out. A client-initiated `CLOSE` ack is exempt. - **COUNT under the same deadline (`buzz-db`).** The fast COUNT path runs e-tag counts under the same transaction-local 20 s cap as SELECT, so WS COUNT and HTTP `/count` produce the terminal timeout too. - **Replica reads surface the deadline (`buzz-db`).** When a replica-routed read is cancelled by `statement_timeout` (SQLSTATE `57014`), the error is returned instead of re-running the query on the writer, which would run the same slow statement. Other replica errors still fall back to the writer. `PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md` records the exception. - **Deadline handling (desktop).** Only the relay's `query timed out` counts as a query deadline. A client-side `request timed out` stays retryable. For one-shot reads a deadline is terminal: the global query retry (budget 1), thread-replies retry and reaction hydration don't re-send the same slow query. Reaction ids held back by a deadline are released when the channel is opened again, so reopening retries them while same-channel re-renders don't. Live subscriptions, in both the renderer and the native archive-sync socket, keep treating a deadline `CLOSED` as transient: they back off and resubscribe with the same filter instead of being dropped. - **Deadline handling (mobile).** Mobile recognizes the deadline on both transports: HTTP `503 {"error":"query timed out"}` and the WS `CLOSED` reason. - The root `ProviderScope` retry policy returns no retry for deadline errors and keeps Riverpod's default backoff for everything else. - Background thread-summary recounts don't retry a deadline. - Forum posts polling pauses while the last load settled on a deadline; reopening the forum retries. - A channel's newest-window load surfaces a deadline instead of falling back to legacy WS history, and the channel list's batched queries return "unavailable" on a deadline instead of fanning out to per-filter fallbacks. An unavailable batch is not treated as empty. Known last-message timestamps and unread state are kept, and unread catch-up moves `lastMessageAt` forward (never backward) the same way live events do, so a cold start with no known timestamp still gets one. - A live subscription that gets a deadline `CLOSED` backs off and resubscribes with the same filter. History requests still fail on it. - Settled load failures show a Retry control, matching desktop's thread-replies error card: a shared `LoadErrorView` on the channel timeline, forum thread and forum posts list, and an icon button beside the thread replies summary. While the thread query has no value, the thread page shows the route snapshot plus live, cached and optimistic replies, with the loading or error status still visible. - Other re-sends (reconnect, foreground resume, live events, the unread backstop, scroll) are not suppressed; each is bounded by the relay's 20 s statement deadline. - **Long-thread seeder.** `just seed-long-thread [replies]` posts one thread (default 187 replies) with some reactions to the local dev relay, for reproducing long threads in the apps. It uses a throwaway key, refuses non-loopback relays and ignores ambient `BUZZ_*` credentials. Writes are paced to fit the stock relay rate limit. **Lifecycle concurrency.** The lock serializes one connection's subscription changes. Other connections can still be delayed by pressure on the shared pubsub command queue, and by serial eviction loops waiting behind a blocked connection. This PR does not claim strict isolation between connections. **Recovery vs. load.** Clients stop automatic retries of the same one-shot read, but they don't remember deadlines across other triggers: reconnect, resume, live events and the unread backstop can each send the read again later, bounded by the 20 s statement deadline. A live subscription that hits the deadline resubscribes with its original filter under the existing 1–30 s backoff, and against a filter that keeps timing out it retries at the 30 s cap indefinitely, so the worst case is one bounded query per subscription every 30 s. `since` is deliberately not advanced to now on resubscribe, because that would skip events missed while the subscription was closed. Unchanged from `main`: when mobile's channel history sync fails with cached messages available, the cached messages are shown without the error or Retry control. ### Benchmark Local reproduction on an isolated bench stack (not committed): - PG16, migrations applied, one 188-reply thread plus 5M background events in the same community. - Each request replays the desktop thread-replies filter through the relay, with `include_aux` on. Times are total ms for 3 runs, with a 60 s client cap. GIN pending-list state is controlled with `VACUUM` (clean) or by refilling the pending list (dirty), and recorded per partition with `pgstatginindex`. Dirty runs held 402 pending pages on every partition's tags index at the start and end of each matrix. Clean runs held 0–1 pages (relay startup writes). | build | GIN pending state | limit 10 | 50 | 100 | 200 (188 ids) | |---|---|---|---|---|---| | `main` | clean | 26 / 16 / 13 | 56 / 51 / 52 | 92 / 93 / 95 | 662 / 639 / 641 | | `main` | dirty | 315 / 298 / 327 | 22141 / 15888 / 16769 | 28992 / 27819 / 28080 | timeout ×3 (>60 s) | | this PR | clean | 35 / 21 / 14 | 57 / 49 / 48 | 82 / 80 / 75 | 153 / 150 / 135 | | this PR | dirty | 290 / 254 / 251 | 1257 / 1204 / 1206 | 2432 / 2378 / 2382 | 4333 / 4706 / 5104 | Baseline `origin/main` `b37e47721`; this PR at `5f2650f5f`, whose e-tag read builders are identical to the current head. At head `1ad81d868` against `origin/main` `15d44dcce`, limit 200 measured 145–179 ms clean (`main` 683–715) and 4.41–4.51 s dirty (`main` timeout ×3). Every completed cell returns the same signed event set: 209 events, 187 replies at limit 200. With `include_aux` off, every cell is ≤66 ms. The thread-window loader (`select_aux`) is unchanged from `main`, and its timings and captured plans match `main`'s. Actual aux-hop statements captured from each relay binary with `auto_explain` (analyze + buffers), execution ms: | build | clean, 100 ids | clean, 188 ids | dirty, 100 ids | dirty, 188 ids | |---|---|---|---|---| | `main` (N-way `OR`) | 40 | 298 | 28785 | 62892 | | this PR (`MATERIALIZED` fence + `ANY`) | 23 | 45 | 1992 | 3539 | **What the fence changes** - On `main`, the planner combines `ORDER BY created_at … LIMIT` with an overestimated match count and walks each partition's primary key backward, expecting to stop early. With few real matches it scans the whole community: the dirty 188-id statement touches ~4.9M shared buffers on `main` vs ~0.6M with the fence. - The fence prevents that early-stop plan. Across the measured cases (1–188 ids, both pending states), the planner used the GIN tags index. The fence doesn't force GIN in general; a broad enough filter could still get a different plan. **Tradeoff:** the fence materializes every match before sorting. In this bench small limits stayed within run-to-run noise of `main`. A single hot id (5,000 reactions, limit 100) measured 30–34 ms unfenced vs 35 ms fenced. **Deadline:** each e-tag read (SELECT and COUNT) runs under a transaction-local `statement_timeout` of 20 s. It only tightens: a shorter operator timeout (`BUZZ_DB_STATEMENT_TIMEOUT_MS`) is kept. It's a per-statement limit, not a per-request one, so a request that runs several pages or hops can take longer than 20 s in total. The benchmark does not force the deadline. --------- Signed-off-by: Will Pfleger Signed-off-by: Duncan Co-authored-by: Duncan Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> --- Justfile | 9 +- PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md | 6 +- crates/buzz-db/src/error.rs | 12 + crates/buzz-db/src/runtime/migration.rs | 5 + crates/buzz-db/src/runtime/mod.rs | 5 + crates/buzz-db/src/runtime/tests.rs | 297 ++++++++ crates/buzz-db/src/store/event.rs | 367 +++++++++- crates/buzz-relay/src/api/bridge.rs | 473 ++++++++++++- crates/buzz-relay/src/api/mod.rs | 58 ++ crates/buzz-relay/src/connection.rs | 21 +- crates/buzz-relay/src/handlers/close.rs | 142 +++- crates/buzz-relay/src/handlers/count.rs | 20 +- crates/buzz-relay/src/handlers/req.rs | 655 ++++++++++++++++-- .../buzz-relay/src/handlers/side_effects.rs | 35 +- crates/buzz-relay/src/state.rs | 8 + .../src/native_relay_client_finite_tests.rs | 1 + .../src/native_relay_client_tests.rs | 26 + .../lib/renderScopedReactions.test.mjs | 27 + .../messages/lib/renderScopedReactions.ts | 28 +- ...nderScopedReactionHydration.jsdom-test.mjs | 124 ++++ .../lib/useRenderScopedReactionHydration.ts | 8 + .../messages/queryDeadlineRetry.test.mjs | 154 ++++ .../src/features/messages/useThreadReplies.ts | 6 +- .../profile/profileBatchResilience.test.mjs | 9 +- desktop/src/shared/api/queryClient.ts | 6 +- .../src/shared/api/relayClosedPolicy.test.mjs | 1 + desktop/src/shared/api/relayClosedPolicy.ts | 5 +- .../shared/api/relayClosedRecovery.test.mjs | 29 + desktop/src/shared/api/relayClosedRecovery.ts | 9 +- desktop/src/shared/lib/relayError.test.mjs | 38 +- desktop/src/shared/lib/relayError.ts | 11 + .../channels/channel_detail_page.dart | 11 +- .../channels/channel_messages_provider.dart | 2 + .../thread_summaries.dart | 3 +- .../features/channels/channels_provider.dart | 62 +- .../channels/thread_detail_helpers.dart | 20 + .../features/channels/thread_detail_page.dart | 12 +- .../thread_detail_page/message_list.dart | 13 + .../lib/features/forum/forum_posts_view.dart | 30 +- .../lib/features/forum/forum_thread_page.dart | 35 +- mobile/lib/main.dart | 35 +- .../lib/shared/relay/relay_closed_policy.dart | 74 +- mobile/lib/shared/relay/relay_session.dart | 7 +- .../lib/shared/widgets/load_error_view.dart | 47 ++ .../channels/channel_detail_page_test.dart | 292 ++++++++ .../channel_messages_provider_test.dart | 51 ++ .../channels/channels_provider_test.dart | 172 +++++ .../features/forum/forum_widgets_test.dart | 180 +++++ .../relay/relay_closed_policy_test.dart | 1 + .../relay_deadline_provider_retry_test.dart | 226 ++++++ .../test/shared/relay/relay_session_test.dart | 32 + schema/schema.sql | 4 + scripts/run-tests.sh | 16 +- scripts/seed-long-thread.sh | 62 ++ 54 files changed, 3769 insertions(+), 213 deletions(-) create mode 100644 desktop/src/features/messages/lib/useRenderScopedReactionHydration.jsdom-test.mjs create mode 100644 desktop/src/features/messages/queryDeadlineRetry.test.mjs create mode 100644 mobile/lib/shared/widgets/load_error_view.dart create mode 100644 mobile/test/shared/relay/relay_deadline_provider_retry_test.dart create mode 100755 scripts/seed-long-thread.sh diff --git a/Justfile b/Justfile index 6377b290db..2f29f5e2a3 100644 --- a/Justfile +++ b/Justfile @@ -461,7 +461,8 @@ test-unit: # `handlers::`: the wider set is mostly Postgres-backed, and five of its # non-postgres_tests cases only "pass" without a database by waiting out # the ~30s sqlx acquire timeout, so they do not belong in the infra-free - # unit job either. + # unit job either. The REQ subscription-lifecycle tests are picked by + # exact name for the same reason: the rest of handlers::req needs a DB. # The third clause adds the NIP-FI HTTP ingress and its router/config # neighbours: nip_fi_http, nip_fi_config, router, api::parse_query_tests, # and the Git transport off_mode_precedence_tests. All are infra-free @@ -472,7 +473,7 @@ test-unit: # because they live in the binary target; the nested # `tests::postgres_tests::` stays in the PostgreSQL lane. cargo nextest run -p buzz-relay --lib --bin buzz-relay \ - -E 'test(/^api::admin::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/)) - test(/^tests::postgres_tests::/))' + -E 'test(/^api::admin::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/)) - test(/^tests::postgres_tests::/))' # ACP author-gate and queue tests protect the trust boundary between # relay events and agent prompts. They are infra-free; ignored lifecycle # tests remain excluded and run in their dedicated integration lanes. @@ -565,6 +566,10 @@ relay: bootstrap _ensure-migrations set +o allexport cargo run -p buzz-relay +# Post one root + N replies (and a few reactions) to the local dev relay, for eyeballing long threads +seed-long-thread replies="187": + ./scripts/seed-long-thread.sh {{replies}} + # Start the relay with the built web UI served from it relay-web: bootstrap _ensure-migrations #!/usr/bin/env bash diff --git a/PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md b/PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md index b6442d4f6d..c64d7e048b 100644 --- a/PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md +++ b/PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md @@ -12,8 +12,10 @@ created: 2026-08-28 routing**: the read may be served from a read replica when `BUZZ_REPLICA_READ_MAX_AGE_MS` is set, subject to the soundness predicate `RoutePredicate::for_query` derives from the query shape. The seam fails closed -to the writer at every error and is a genuine no-op until the budget is -configured (`crates/buzz-db/src/lib.rs`). +to the writer on replica errors, with one deliberate exception: a statement +cancelled by `statement_timeout` (SQLSTATE `57014`) is surfaced rather than +re-run, because the writer would run the same slow statement. It is a genuine +no-op until the budget is configured (`crates/buzz-db/src/lib.rs`). ## Routing rule diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index e7d7f2d324..4f15cc2b24 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -84,5 +84,17 @@ pub enum DbError { LastOperator, } +impl DbError { + /// Whether Postgres cancelled the statement (SQLSTATE 57014: + /// `statement_timeout` or an explicit cancel). Re-running such a query on + /// the writer would only repeat the same expensive work. + pub fn is_statement_cancelled(&self) -> bool { + matches!( + self, + DbError::Sqlx(sqlx::Error::Database(db)) if db.code().as_deref() == Some("57014") + ) + } +} + /// Convenience alias for `Result`. pub type Result = std::result::Result; diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 66e3645915..9eae1dc72b 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -767,6 +767,11 @@ mod postgres_tests { .as_str() .contains("CREATE INDEX idx_events_tags_gin")); assert!(!migrations[0].sql.as_str().contains("idx_events_tags_gin")); + // schema.sql (CI / isolated relay bootstrap) must carry the same index, + // or e-tag reads there run on plans prod never sees. + assert!(include_str!("../../../../schema/schema.sql").contains( + "CREATE INDEX idx_events_tags_gin ON events USING GIN (tags jsonb_path_ops)" + )); // NIP-AM (kind 44200) FTS exclusion: additive migration, never folded // into 0001 — folding would change 0001's checksum and break brownfield diff --git a/crates/buzz-db/src/runtime/mod.rs b/crates/buzz-db/src/runtime/mod.rs index 150c8f4768..bc1e0374c7 100644 --- a/crates/buzz-db/src/runtime/mod.rs +++ b/crates/buzz-db/src/runtime/mod.rs @@ -201,6 +201,8 @@ impl ReadSession { /// /// If the proved replica transaction fails mid-request, the session /// permanently degrades to the writer and the query is re-run there. + /// Exception: a statement cancelled by `statement_timeout` (57014) is + /// returned as-is, since the writer would run the same slow statement. /// The writer is always at or ahead of any replica replay position, so /// the degraded follow-up can only observe *more* than the proof-time /// snapshot, never less — fresher aux rows, the same failure semantics @@ -211,6 +213,9 @@ impl ReadSession { ReadSessionInner::Replica { tx, writer } => { match event::query_events_on(tx, q).await { Ok(rows) => return Ok(rows), + // A cancelled statement (timeout) would be just as slow on + // the writer; surface it instead of doubling the cost. + Err(e) if e.is_statement_cancelled() => return Err(e), Err(e) => { tracing::warn!( error = %e, diff --git a/crates/buzz-db/src/runtime/tests.rs b/crates/buzz-db/src/runtime/tests.rs index 21d0e73823..3aa8e90685 100644 --- a/crates/buzz-db/src/runtime/tests.rs +++ b/crates/buzz-db/src/runtime/tests.rs @@ -3313,5 +3313,302 @@ async fn floor_guard_blocks_updates_that_move_rows_below_the_fence() { drop_scratch_db(&admin, pool, &name).await; } +// ---- Statement-deadline cancellation vs. replica fallback ---------------- +// +// A replica read cancelled by `statement_timeout` (SQLSTATE 57014) must be +// surfaced, not re-run on the writer: the writer would be just as slow and +// the relay maps it to a distinct non-retried "query timed out". Every other +// replica failure keeps failing closed to the writer. + +/// Writer and replica scratch DBs holding one root plus an `e`-tagged reply +/// that mentions `mentioned` — the writer copy says "writer-reply" so a +/// writer re-run is observable. Returns the reply-targeted query shape. +struct EtagFixture { + admin: PgPool, + writer: PgPool, + wname: String, + replica: PgPool, + rname: String, + cid: CommunityId, + root_hex: String, + mentioned_hex: String, +} + +async fn e_tag_fixture(prefix: &str) -> EtagFixture { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, &format!("{prefix}_w")).await; + let (replica, rname) = create_scratch_db(&admin, &format!("{prefix}_r")).await; + let author = nostr::Keys::generate(); + let mentioned = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + let base = 1_700_000_000u64; + let root = signed_event_at(&author, "root", base); + for (pool, content) in [(&writer, "writer-reply"), (&replica, "replica-reply")] { + seed_community_channel(pool, community, channel, &author).await; + insert_top_level(pool, community, channel, &root).await; + let reply = nostr::EventBuilder::new(nostr::Kind::Custom(9), content) + .tags([ + nostr::Tag::event(root.id), + nostr::Tag::public_key(mentioned.public_key()), + ]) + .custom_created_at(nostr::Timestamp::from(base + 10)) + .sign_with_keys(&author) + .expect("sign reply"); + insert_top_level(pool, community, channel, &reply).await; + crate::insert_mentions( + pool, + CommunityId::from_uuid(community), + &reply, + Some(channel), + ) + .await + .expect("insert mentions"); + } + EtagFixture { + admin, + writer, + wname, + replica, + rname, + cid: CommunityId::from_uuid(community), + root_hex: root.id.to_hex(), + mentioned_hex: mentioned.public_key().to_hex(), + } +} + +impl EtagFixture { + fn e_tag_query(&self) -> EventQuery { + let mut q = EventQuery::for_community(self.cid); + q.e_tags = Some(vec![self.root_hex.clone(), "00".repeat(32)]); + q + } + + /// A routed `Db` whose reader pool connects fresh (so it picks up any + /// `ALTER DATABASE` settings made on the replica), with the fence open + /// and a bounded budget so unpinned reads route to the replica. + async fn routed_db(&self) -> Db { + let base = admin_url().await; + let url = format!("{}/{}", &base[..base.rfind('/').expect("path")], self.rname); + let reader = PgPool::connect(&url).await.expect("connect reader"); + let mut db = Db::from_pools(self.writer.clone(), reader); + db.fence().force_open_for_tests(chrono::Utc::now()); + db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); + db + } + + async fn drop(self) { + drop_scratch_db(&self.admin, self.replica, &self.rname).await; + drop_scratch_db(&self.admin, self.writer, &self.wname).await; + } +} + +fn contents(rows: &[StoredEvent]) -> Vec<&str> { + rows.iter().map(|e| e.event.content.as_str()).collect() +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn replica_statement_cancel_propagates_without_writer_rerun() { + let fx = e_tag_fixture("cxl").await; + // A short operator cap on the replica (kept, not raised, by the e-tag + // deadline), plus a lock that parks every `events` read past it: the + // heartbeat proof still succeeds, then the routed read is cancelled. + sqlx::query(sqlx::AssertSqlSafe(format!( + "ALTER DATABASE {} SET statement_timeout = '300ms'", + fx.rname + ))) + .execute(&fx.admin) + .await + .expect("set replica statement_timeout"); + let db = fx.routed_db().await; + let q = fx.e_tag_query(); + + // Healthy control: the replica serves before it is locked. + let rows = db.query_events_routed("t_cxl", &q).await.expect("healthy"); + assert!( + contents(&rows).contains(&"replica-reply"), + "fixture must route to replica" + ); + + let mut locker = fx.replica.begin().await.expect("begin locker"); + sqlx::query("LOCK TABLE events IN ACCESS EXCLUSIVE MODE") + .execute(&mut *locker) + .await + .expect("lock replica events"); + + let cancelled = |r: crate::Result<()>, what: &str| match r { + Err(e) => assert!(e.is_statement_cancelled(), "{what}: want 57014, got {e}"), + Ok(()) => panic!("{what}: replica cancel was re-run on the writer"), + }; + cancelled( + db.query_events_routed("t_cxl", &q).await.map(drop), + "query_events_routed", + ); + cancelled( + db.query_events_routed_bounded("t_cxl", &q).await.map(drop), + "query_events_routed_bounded", + ); + cancelled( + db.count_events_routed("t_cxl", &q).await.map(drop), + "count_events_routed", + ); + let root_id = hex::decode(&fx.root_hex).expect("hex"); + cancelled( + db.get_events_by_ids_routed("t_cxl", fx.cid, &[root_id.as_slice()]) + .await + .map(drop), + "get_events_by_ids_routed", + ); + + locker.rollback().await.expect("unlock"); + drop(db); + fx.drop().await; +} + +/// [`replica_statement_cancel_propagates_without_writer_rerun`] for the +/// request-scoped [`ReadSession`]: a cancelled aux read inside the proved +/// replica transaction surfaces 57014 and does not degrade to the writer. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn read_session_statement_cancel_propagates_without_writer_rerun() { + let fx = e_tag_fixture("scxl").await; + let db = fx.routed_db().await; + let reader_tx = db + .read_pool + .as_ref() + .expect("reader pool") + .begin() + .await + .expect("begin reader tx"); + let mut session = ReadSession { + inner: ReadSessionInner::Replica { + tx: reader_tx, + writer: fx.writer.clone(), + }, + }; + let rows = session + .query_events(&fx.e_tag_query()) + .await + .expect("healthy"); + assert_eq!(contents(&rows), vec!["replica-reply"]); + + // Force the next statement in the session over a short cap. + let ReadSessionInner::Replica { tx, .. } = &mut session.inner else { + unreachable!() + }; + sqlx::query("SET LOCAL statement_timeout = '50ms'") + .execute(&mut **tx) + .await + .expect("cap session"); + let mut locker = fx.replica.begin().await.expect("begin locker"); + sqlx::query("LOCK TABLE events IN ACCESS EXCLUSIVE MODE") + .execute(&mut *locker) + .await + .expect("lock replica events"); + + match session.query_events(&fx.e_tag_query()).await { + Err(e) => assert!(e.is_statement_cancelled(), "want 57014, got {e}"), + Ok(rows) => panic!("cancel re-ran on the writer: {:?}", contents(&rows)), + } + assert!( + session.is_replica(), + "a cancel must not degrade the session" + ); + + locker.rollback().await.expect("unlock"); + drop(session); + drop(db); + fx.drop().await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn replica_non_cancel_failure_still_falls_back_to_writer() { + let fx = e_tag_fixture("nfb").await; + let db = fx.routed_db().await; + let q = fx.e_tag_query(); + let rows = db.query_events_routed("t_nfb", &q).await.expect("healthy"); + assert!( + contents(&rows).contains(&"replica-reply"), + "fixture must route to replica" + ); + + // Break the replica after the proof point (heartbeat table intact). + sqlx::query("DROP TABLE events CASCADE") + .execute(&fx.replica) + .await + .expect("drop replica events"); + + for rows in [ + db.query_events_routed("t_nfb", &q).await, + db.query_events_routed_bounded("t_nfb", &q).await, + ] { + let rows = rows.expect("non-cancel failure must fall back"); + assert!( + contents(&rows).contains(&"writer-reply"), + "{:?}", + contents(&rows) + ); + } + assert_eq!( + db.count_events_routed("t_nfb", &q) + .await + .expect("count falls back"), + 1 + ); + + drop(db); + fx.drop().await; +} + +/// The `#p`-joined SELECT (fenced, `e.` column prefix) and the COUNT `ANY` +/// path, executed with real bound values rather than only rendered SQL. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn e_tag_any_runs_with_real_binds_on_p_join_and_count() { + let fx = e_tag_fixture("pjn").await; + let mut q = fx.e_tag_query(); + + let rows = event::query_events(&fx.writer, &q) + .await + .expect("e-tag select"); + assert_eq!(contents(&rows), vec!["writer-reply"]); + assert_eq!( + event::count_events(&fx.writer, &q) + .await + .expect("e-tag count"), + 1 + ); + + q.p_tag_hex = Some(fx.mentioned_hex.clone()); + let rows = event::query_events(&fx.writer, &q) + .await + .expect("#p + e-tag select"); + assert_eq!(contents(&rows), vec!["writer-reply"]); + assert_eq!( + event::count_events(&fx.writer, &q) + .await + .expect("#p + e-tag count"), + 1 + ); + + q.p_tag_hex = Some("11".repeat(32)); + assert!(event::query_events(&fx.writer, &q) + .await + .expect("miss") + .is_empty()); + assert_eq!( + event::count_events(&fx.writer, &q) + .await + .expect("miss count"), + 0 + ); + + fx.drop().await; +} + #[path = "tests/thread_window_postgres_tests.rs"] mod thread_window_postgres_tests; diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index 6259a02c5d..23082102e9 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -503,13 +503,42 @@ pub(crate) async fn query_events_on( return Ok(vec![]); } + let mut qb = build_query_events_sql(q); + let rows = if q.e_tags.is_some() { + fetch_with_e_tag_deadline(conn, &mut qb).await? + } else { + qb.build().fetch_all(&mut *conn).await? + }; + + let mut out = Vec::with_capacity(rows.len()); + for row in rows { + if let Some(ev) = row_to_stored_event(row)? { + out.push(ev); + } + } + Ok(out) +} + +/// SQL for [`query_events_on`], after its match-nothing early returns. +fn build_query_events_sql(q: &EventQuery) -> QueryBuilder { let clamp = q.max_limit.unwrap_or(DEFAULT_MAX_PAGE_LIMIT); let limit_val = q.limit.unwrap_or(100).min(clamp); let offset_val = q.offset.unwrap_or(0); + // e-tag reads filter inside a MATERIALIZED CTE and sort/limit outside it. + // With `ORDER BY created_at LIMIT n` in the same scope, the planner + // overestimates matches and walks each partition's pkey backward, testing + // every community row against the id set; the fence keeps it on the GIN + // tags index. + let e_tag_fence = q.e_tags.is_some(); let mut qb: QueryBuilder = if let Some(ref p_hex) = q.p_tag_hex { // Join against event_mentions for #p-filtered queries (indexed). - let mut b = QueryBuilder::new( + let mut b = QueryBuilder::new(if e_tag_fence { + "WITH m AS MATERIALIZED (" + } else { + "" + }); + b.push( "SELECT e.id, e.pubkey, e.created_at, e.kind, e.tags, e.content, \ e.sig, e.received_at, e.channel_id \ FROM events e \ @@ -524,7 +553,12 @@ pub(crate) async fn query_events_on( b.push_bind(p_hex.to_ascii_lowercase()); b } else { - let mut b = QueryBuilder::new( + let mut b = QueryBuilder::new(if e_tag_fence { + "WITH m AS MATERIALIZED (" + } else { + "" + }); + b.push( "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ FROM events WHERE community_id = ", ); @@ -614,17 +648,7 @@ pub(crate) async fn query_events_on( // the dominant scroll-back cost (~1.7s/page on staging). if let Some(ref e_tags) = q.e_tags { if !e_tags.is_empty() { - qb.push(" AND ("); - for (i, hex_id) in e_tags.iter().enumerate() { - if i > 0 { - qb.push(" OR "); - } - // Build the JSONB literal: [["e",""]] - let containment = serde_json::json!([["e", hex_id]]); - qb.push(format!("{col_prefix}tags @> ")); - qb.push_bind(containment); - } - qb.push(")"); + push_e_tag_filter(&mut qb, col_prefix, e_tags); } } @@ -704,21 +728,58 @@ pub(crate) async fn query_events_on( // same second. No existing index covers this trailing column — Postgres // sorts in memory, which is fine at current scale. If query performance // degrades, add a composite index like `(pubkey, kind, created_at DESC, id ASC)`. - qb.push(format!( - " ORDER BY {col_prefix}created_at DESC, {col_prefix}id ASC LIMIT " - )); + if e_tag_fence { + qb.push(") SELECT * FROM m ORDER BY created_at DESC, id ASC LIMIT "); + } else { + qb.push(format!( + " ORDER BY {col_prefix}created_at DESC, {col_prefix}id ASC LIMIT " + )); + } qb.push_bind(limit_val); qb.push(" OFFSET ").push_bind(offset_val); + qb +} - let rows = qb.build().fetch_all(&mut *conn).await?; +/// Server-side deadline for e-tag (aux-closure / `#e`) reads, kept under the +/// desktop's 30 s request timeout so a runaway query is cancelled once no +/// client can still be waiting for it. It only ever tightens the effective +/// `statement_timeout`: a shorter operator cap (`BUZZ_DB_STATEMENT_TIMEOUT_MS`) +/// is kept, and a disabled (`0`) or longer one is lowered to 20 s. The limit is +/// per statement, not per request. +const E_TAG_STATEMENT_TIMEOUT_SQL: &str = "SELECT set_config('statement_timeout', \ + CASE WHEN current_setting('statement_timeout')::interval = interval '0' \ + OR current_setting('statement_timeout')::interval > interval '20s' \ + THEN '20s' ELSE current_setting('statement_timeout') END, true)"; - let mut out = Vec::with_capacity(rows.len()); - for row in rows { - if let Some(ev) = row_to_stored_event(row)? { - out.push(ev); - } - } - Ok(out) +/// Run an e-tag read under [`E_TAG_STATEMENT_TIMEOUT_SQL`] without changing the +/// deadline for anything else on `conn`. `begin()` opens a transaction on an +/// autocommit writer connection or a savepoint inside the replica's read +/// transaction; rolling it back (the read has no writes to keep) reverts the +/// transaction-local `set_config` in both cases. +async fn fetch_with_e_tag_deadline( + conn: &mut sqlx::PgConnection, + qb: &mut QueryBuilder, +) -> Result> { + let mut tx = sqlx::Connection::begin(&mut *conn).await?; + sqlx::query(E_TAG_STATEMENT_TIMEOUT_SQL) + .execute(&mut *tx) + .await?; + let rows = qb.build().fetch_all(&mut *tx).await?; + tx.rollback().await?; + Ok(rows) +} + +/// e-tag pushdown as one array-bound containment test instead of an N-way +/// `OR` chain, so planner cost does not scale with the number of referenced +/// ids (the thread aux hop sends one id per reply). +fn push_e_tag_filter(qb: &mut QueryBuilder, col_prefix: &str, e_tags: &[String]) { + let containments: Vec = e_tags + .iter() + .map(|hex_id| serde_json::json!([["e", hex_id]])) + .collect(); + qb.push(format!(" AND {col_prefix}tags @> ANY(")) + .push_bind(containments) + .push("::jsonb[])"); } pub(crate) fn row_to_stored_event(row: sqlx::postgres::PgRow) -> Result> { @@ -884,16 +945,7 @@ pub(crate) async fn count_events_on(conn: &mut sqlx::PgConnection, q: &EventQuer if let Some(ref e_tags) = q.e_tags { if !e_tags.is_empty() { - qb.push(" AND ("); - for (i, hex_id) in e_tags.iter().enumerate() { - if i > 0 { - qb.push(" OR "); - } - let containment = serde_json::json!([["e", hex_id]]); - qb.push(format!("{col_prefix}tags @> ")); - qb.push_bind(containment); - } - qb.push(")"); + push_e_tag_filter(&mut qb, col_prefix, e_tags); } } @@ -920,7 +972,16 @@ pub(crate) async fn count_events_on(conn: &mut sqlx::PgConnection, q: &EventQuer } } - let row = qb.build().fetch_one(&mut *conn).await?; + let row = if q.e_tags.as_deref().is_some_and(|e| !e.is_empty()) { + // Run under the same transaction-local deadline as `query_events_on` + // so a COUNT over a long thread cannot stall indefinitely either. + let rows = fetch_with_e_tag_deadline(conn, &mut qb).await?; + rows.into_iter() + .next() + .ok_or_else(|| DbError::InvalidData("COUNT returned no rows".to_string()))? + } else { + qb.build().fetch_one(&mut *conn).await? + }; let cnt: i64 = row.try_get("cnt")?; Ok(cnt) @@ -1842,7 +1903,8 @@ impl Db { /// seam is gated on `BUZZ_REPLICA_READ_MAX_AGE_MS` (default off): when /// unset, even covered-eligible queries stay on the writer, so merging /// this seam is a true no-op until the budget is configured. Every - /// failure fails closed to the writer. + /// failure fails closed to the writer, except a statement-timeout cancel + /// (57014), which is surfaced rather than re-run. #[datastore_span(name = "query_events_routed", system = "postgresql")] pub async fn query_events_routed( &self, @@ -1864,6 +1926,12 @@ impl Db { Self::record_route(path, "replica", reason); Ok(events) } + // A cancelled statement (timeout) would be just as slow on the + // writer; surface it instead of doubling the cost. + Err(e) if e.is_statement_cancelled() => { + Self::record_route(path, "replica", "statement_cancelled"); + Err(e) + } Err(e) => { // Mid-query replica failure: fail closed to the // writer rather than surfacing a routed error. @@ -1917,6 +1985,12 @@ impl Db { Self::record_route(path, "replica", reason); Ok(events) } + // A cancelled statement (timeout) would be just as slow on the + // writer; surface it instead of doubling the cost. + Err(e) if e.is_statement_cancelled() => { + Self::record_route(path, "replica", "statement_cancelled"); + Err(e) + } Err(e) => { tracing::warn!(path, "replica read failed; re-running on writer: {e}"); Self::record_route(path, "writer", "replica_error"); @@ -1975,6 +2049,12 @@ impl Db { Self::record_route(path, "replica", reason); Ok(count) } + // A cancelled statement (timeout) would be just as slow on the + // writer; surface it instead of doubling the cost. + Err(e) if e.is_statement_cancelled() => { + Self::record_route(path, "replica", "statement_cancelled"); + Err(e) + } Err(e) => { tracing::warn!(path, "replica count failed; re-running on writer: {e}"); Self::record_route(path, "writer", "replica_error"); @@ -2228,6 +2308,12 @@ impl Db { Self::record_route(path, "replica", reason); Ok(events) } + // A cancelled statement (timeout) would be just as slow on the + // writer; surface it instead of doubling the cost. + Err(e) if e.is_statement_cancelled() => { + Self::record_route(path, "replica", "statement_cancelled"); + Err(e) + } Err(e) => { tracing::warn!(path, "replica read failed; re-running on writer: {e}"); Self::record_route(path, "writer", "replica_error"); @@ -2348,6 +2434,41 @@ impl Db { } } +#[cfg(test)] +mod e_tag_filter_shape_tests { + use super::*; + + /// The aux closure sends one e-tag per reply. The id set must be one + /// array-bound predicate (never an N-way `OR`), and it must be filtered + /// inside a MATERIALIZED CTE with ORDER BY/LIMIT outside: in the same + /// scope the planner flips to a backward pkey walk over the whole + /// community (the long-thread timeout). + #[test] + fn e_tag_query_filters_in_materialized_fence_before_order_limit() { + let mut q = EventQuery::for_community(CommunityId::from_uuid(uuid::Uuid::nil())); + q.kinds = Some(vec![7]); + q.e_tags = Some((0..188).map(|i| format!("{i:064x}")).collect()); + let qb = build_query_events_sql(&q); + let sql = qb.sql().as_str().to_owned(); + + assert!(sql.starts_with("WITH m AS MATERIALIZED (SELECT ")); + assert_eq!(sql.matches("tags @> ANY(").count(), 1); + assert!(!sql.contains(" OR ")); + let fence_end = sql.find(") SELECT * FROM m ").expect("fence closes"); + assert!(sql.contains("::jsonb[])")); + assert!(!sql[..fence_end].contains("ORDER BY")); + assert!(!sql[..fence_end].contains("LIMIT")); + assert!(sql[fence_end..].contains("ORDER BY created_at DESC, id ASC LIMIT ")); + } + + #[test] + fn non_e_tag_query_is_not_fenced() { + let q = EventQuery::for_community(CommunityId::from_uuid(uuid::Uuid::nil())); + let qb = build_query_events_sql(&q); + assert!(!qb.sql().as_str().contains("MATERIALIZED")); + } +} + #[cfg(test)] mod postgres_tests { use super::*; @@ -2401,6 +2522,180 @@ mod postgres_tests { id } + /// Effective `statement_timeout` observed *inside* the e-tag deadline, and + /// the value restored on `conn` afterwards. + async fn deadline_inside_and_after(conn: &mut sqlx::PgConnection) -> (String, String) { + let mut qb = QueryBuilder::new("SELECT current_setting('statement_timeout')"); + let rows = fetch_with_e_tag_deadline(conn, &mut qb) + .await + .expect("deadline read"); + let inside: String = rows[0].get(0); + let after: String = sqlx::query_scalar("SHOW statement_timeout") + .fetch_one(&mut *conn) + .await + .expect("show timeout"); + (inside, after) + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn e_tag_deadline_only_tightens_and_restores_timeout() { + let pool = setup_pool().await; + // (operator setting, expected effective value inside the read) + let cases = [("0", "20s"), ("5s", "5s"), ("1min", "20s")]; + for (setting, expected) in cases { + // Autocommit connection (writer path). + let mut conn = pool.acquire().await.expect("acquire"); + sqlx::query("SELECT set_config('statement_timeout', $1, false)") + .bind(setting) + .execute(&mut *conn) + .await + .expect("set session timeout"); + let restored: String = sqlx::query_scalar("SHOW statement_timeout") + .fetch_one(&mut *conn) + .await + .expect("show timeout"); + let (inside, after) = deadline_inside_and_after(&mut conn).await; + assert_eq!(inside, expected, "autocommit, operator {setting}"); + assert_eq!(after, restored, "autocommit restore, operator {setting}"); + + // Nested in an outer read transaction (replica path -> savepoint). + let mut tx = sqlx::Connection::begin(&mut *conn).await.expect("begin"); + let (inside, after) = deadline_inside_and_after(&mut tx).await; + assert_eq!(inside, expected, "savepoint, operator {setting}"); + assert_eq!(after, restored, "savepoint restore, operator {setting}"); + tx.rollback().await.expect("rollback outer"); + + sqlx::query("RESET statement_timeout") + .execute(&mut *conn) + .await + .expect("reset"); + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn e_tag_deadline_restores_timeout_after_error() { + let pool = setup_pool().await; + let mut conn = pool.acquire().await.expect("acquire"); + sqlx::query("SET statement_timeout = '1min'") + .execute(&mut *conn) + .await + .expect("set session timeout"); + // Autocommit connection (writer path). + let mut qb = QueryBuilder::new("SELECT 1/0"); + let err = fetch_with_e_tag_deadline(&mut conn, &mut qb).await; + assert!(err.is_err(), "division by zero must fail"); + let after: String = sqlx::query_scalar("SHOW statement_timeout") + .fetch_one(&mut *conn) + .await + .expect("connection usable after error"); + assert_eq!(after, "1min", "autocommit restore"); + + // Nested in an outer read transaction (replica path -> savepoint): + // the failed savepoint must roll back and leave the outer tx usable. + let mut tx = sqlx::Connection::begin(&mut *conn).await.expect("begin"); + let mut qb = QueryBuilder::new("SELECT 1/0"); + let err = fetch_with_e_tag_deadline(&mut tx, &mut qb).await; + assert!(err.is_err(), "division by zero must fail"); + let after: String = sqlx::query_scalar("SHOW statement_timeout") + .fetch_one(&mut *tx) + .await + .expect("outer transaction usable after savepoint error"); + assert_eq!(after, "1min", "savepoint restore"); + tx.rollback().await.expect("rollback outer"); + sqlx::query("RESET statement_timeout") + .execute(&mut *conn) + .await + .expect("reset"); + } + + /// Pins the dispatch: an e-tag read through the production + /// `query_events_on` is cancelled at the 20 s deadline even with the + /// session timeout disabled, while `events` is locked. (Slow by design.) + #[tokio::test] + #[ignore = "requires Postgres"] + async fn e_tag_read_is_cancelled_at_deadline_when_timeout_disabled() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let mut locker = pool.begin().await.expect("begin locker"); + sqlx::query("LOCK TABLE events IN ACCESS EXCLUSIVE MODE") + .execute(&mut *locker) + .await + .expect("lock events"); + + let mut conn = pool.acquire().await.expect("acquire"); + sqlx::query("SET statement_timeout = 0") + .execute(&mut *conn) + .await + .expect("disable timeout"); + let mut q = EventQuery::for_community(community); + q.e_tags = Some(vec!["00".repeat(32)]); + let started = std::time::Instant::now(); + let result = tokio::time::timeout( + std::time::Duration::from_secs(40), + query_events_on(&mut conn, &q), + ) + .await + .expect("deadline must fire before the 40 s guard"); + let err = result.expect_err("locked read must be cancelled"); + assert!(err.is_statement_cancelled(), "want 57014, got {err}"); + assert!(started.elapsed() >= std::time::Duration::from_secs(19)); + + locker.rollback().await.expect("unlock"); + sqlx::query("RESET statement_timeout") + .execute(&mut *conn) + .await + .expect("reset"); + } + + /// Pins the dispatch: a COUNT with an e-tag filter through the production + /// `count_events_on` is cancelled at the 20 s deadline even with the + /// session timeout disabled, while `events` is locked. Connection and + /// setting are restored afterwards. (Slow by design.) + #[tokio::test] + #[ignore = "requires Postgres"] + async fn count_events_cancels_at_e_tag_deadline_when_timeout_disabled() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let mut locker = pool.begin().await.expect("begin locker"); + sqlx::query("LOCK TABLE events IN ACCESS EXCLUSIVE MODE") + .execute(&mut *locker) + .await + .expect("lock events"); + + let mut conn = pool.acquire().await.expect("acquire"); + sqlx::query("SET statement_timeout = 0") + .execute(&mut *conn) + .await + .expect("disable timeout"); + let mut q = EventQuery::for_community(community); + q.e_tags = Some(vec!["00".repeat(32)]); + let started = std::time::Instant::now(); + let result = tokio::time::timeout( + std::time::Duration::from_secs(40), + count_events_on(&mut conn, &q), + ) + .await + .expect("deadline must fire before the 40 s guard"); + let err = result.expect_err("locked COUNT must be cancelled"); + assert!(err.is_statement_cancelled(), "want 57014, got {err}"); + assert!(started.elapsed() >= std::time::Duration::from_secs(19)); + + // Connection and session timeout are restored after the error. + let after: String = sqlx::query_scalar("SHOW statement_timeout") + .fetch_one(&mut *conn) + .await + .expect("connection usable after cancellation"); + assert_eq!(after, "0", "timeout must be restored to disabled"); + + locker.rollback().await.expect("unlock"); + sqlx::query("RESET statement_timeout") + .execute(&mut *conn) + .await + .expect("reset"); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn event_insert_in_existing_transaction_rolls_back_with_caller() { diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 13437d9c0d..b5aa6d553b 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -22,7 +22,7 @@ use crate::handlers::ingest::{IngestAuth, IngestError}; use crate::nip_fi_http::{admit_nip_fi_http_on_state, Nip98Proof}; use crate::state::AppState; -use super::{api_error, internal_error, not_found, parse_query_or_400}; +use super::{api_error, db_read_error, internal_error, not_found, parse_query_or_400}; mod thread_roots; mod thread_window; @@ -772,7 +772,7 @@ async fn handle_channel_window_filter( &mut AuxReader::Session(&mut session), ) .await - .map_err(|e| internal_error(&format!("window aux error: {e}")))?; + .map_err(|e| db_read_error("window aux error", &e))?; for se in aux_events { if !seen_aux.insert(se.event.id) { continue; @@ -1635,7 +1635,7 @@ async fn query_events_authed( &mut AuxReader::Routed(&state.db, "bridge_thread_aux"), ) .await - .map_err(|e| internal_error(&format!("thread aux query error: {e}")))?; + .map_err(|e| db_read_error("thread aux query error", &e))?; for se in aux_events { if !seen_aux.insert(se.event.id) || !event_in_accessible_channel(&se, &accessible_channels) @@ -1792,7 +1792,7 @@ async fn query_events_authed( } } Err(e) => { - return Err(internal_error(&format!("query error: {e}"))); + return Err(db_read_error("query error", &e)); } } } @@ -2058,7 +2058,7 @@ async fn count_events_authed( match state.db.count_events_routed("bridge_count", &query).await { Ok(n) => total += n as u64, Err(e) => { - return Err(internal_error(&format!("count error: {e}"))); + return Err(db_read_error("count error", &e)); } } } else { @@ -2093,7 +2093,7 @@ async fn count_events_authed( } } Err(e) => { - return Err(internal_error(&format!("count error: {e}"))); + return Err(db_read_error("count error", &e)); } } } @@ -2129,7 +2129,7 @@ async fn count_events_authed( match state.db.count_events_routed("bridge_count", &query).await { Ok(n) => total += n as u64, Err(e) => { - return Err(internal_error(&format!("count error: {e}"))); + return Err(db_read_error("count error", &e)); } } } else { @@ -2163,7 +2163,7 @@ async fn count_events_authed( } } Err(e) => { - return Err(internal_error(&format!("count error: {e}"))); + return Err(db_read_error("count error", &e)); } } } @@ -2314,7 +2314,7 @@ async fn handle_bridge_search( .db .get_events_by_ids_routed("bridge_search_hydrate", tenant.community(), &id_refs) .await - .map_err(|e| internal_error(&format!("search fetch error: {e}")))?; + .map_err(|e| db_read_error("search fetch error", &e))?; // Build lookup map to preserve FTS relevance ordering. let event_map: std::collections::HashMap<[u8; 32], &buzz_core::StoredEvent> = stored_events @@ -7606,4 +7606,459 @@ mod postgres_tests { drop_scratch(&admin2, replica_pool, &replica_name).await; }); } + + // ── Statement-cancel propagation through every routed-read caller ───────── + // + // A replica whose `events` table is locked past its 300ms operator + // `statement_timeout` cancels every routed read with 57014, which the + // routed helpers now propagate instead of re-running on the writer. Every + // COUNT arm (fast/fallback × with/without `#h`) and search hydrate must + // then answer with the stable timeout contract on both transports: + // HTTP 503 `query timed out`, WS CLOSED `error: query timed out`. The + // ordinary-error controls rename `events` so reads fail with 42P01 and + // must stay a generic 500 / raw WS error. + + struct CancelFixture { + admin: sqlx::PgPool, + writer: sqlx::PgPool, + replica: sqlx::PgPool, + names: [String; 2], + state: Arc, + host: String, + community: buzz_core::CommunityId, + channel: String, + root: String, + reader: Keys, + } + + async fn cancel_fixture() -> CancelFixture { + use buzz_db::channel::{ChannelType, ChannelVisibility}; + let admin_url = crate::test_support::database_url(); + let admin = sqlx::PgPool::connect(&admin_url).await.expect("admin pool"); + let base = &admin_url[..admin_url.rfind('/').expect("db path")]; + let community = uuid::Uuid::new_v4(); + let channel = uuid::Uuid::new_v4(); + let host = format!("cancel-{}.local", community.simple()); + let author = Keys::generate(); + let root = EventBuilder::new(Kind::Custom(9), "root needle") + .tag(Tag::parse(["h", &channel.to_string()]).expect("h")) + .sign_with_keys(&author) + .expect("sign root"); + let mut pools = Vec::new(); + let mut names = Vec::new(); + for role in ["w", "r"] { + let name = format!("cancel_{role}_{}", uuid::Uuid::new_v4().simple()); + sqlx::query(sqlx::AssertSqlSafe(format!("CREATE DATABASE {name}"))) + .execute(&admin) + .await + .expect("create scratch db"); + if role == "r" { + sqlx::query(sqlx::AssertSqlSafe(format!( + "ALTER DATABASE {name} SET statement_timeout = '300ms'" + ))) + .execute(&admin) + .await + .expect("replica statement_timeout"); + } + let pool = sqlx::PgPool::connect(&format!("{base}/{name}")) + .await + .expect("connect scratch"); + buzz_db::migration::run_migrations(&pool) + .await + .expect("migrate"); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community) + .bind(&host) + .execute(&pool) + .await + .expect("seed community"); + let cid = buzz_core::CommunityId::from_uuid(community); + buzz_db::channel::create_channel_with_id( + &pool, + cid, + channel, + &format!("cancel-{}", channel.simple()), + ChannelType::Stream, + ChannelVisibility::Open, + None, + author.public_key().to_bytes().as_slice(), + None, + ) + .await + .expect("create channel"); + buzz_db::Db::from_pool(pool.clone()) + .insert_event(cid, &root, Some(channel)) + .await + .expect("insert root"); + pools.push(pool); + names.push(name); + } + let replica = pools.pop().expect("replica"); + let writer = pools.pop().expect("writer"); + + let mut config = crate::config::Config::from_env().expect("config"); + config.redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + config.relay_url = "wss://cancel-test.local".to_string(); + config.require_auth_token = false; + config.require_relay_membership = false; + let mut db = buzz_db::Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub"), + ); + let (mut state, _audit_shutdown) = crate::state::AppState::new( + config.clone(), + db.clone(), + redis_pool, + buzz_audit::AuditService::new(writer.clone()), + pubsub, + buzz_auth::AuthService::new(config.auth.clone()), + buzz_search::SearchService::new(writer.clone()), + Arc::new(buzz_workflow::WorkflowEngine::new( + db, + buzz_workflow::WorkflowConfig::default(), + )), + Keys::generate(), + buzz_media::MediaStorage::new(&config.media).expect("media storage"), + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + CancelFixture { + admin, + writer, + replica, + names: [names[0].clone(), names[1].clone()], + state: Arc::new(state), + host, + community: buzz_core::CommunityId::from_uuid(community), + channel: channel.to_string(), + root: root.id.to_hex(), + reader: Keys::generate(), + } + } + + impl CancelFixture { + /// The four COUNT arms: fast (fully pushable) and fallback (`#t` is + /// not pushable, `#e` still reaches SQL), each with and without `#h`. + fn count_cases(&self) -> Vec<(&'static str, serde_json::Value)> { + let e = [&self.root]; + let h = [&self.channel]; + vec![ + ( + "fast #h", + serde_json::json!({"kinds": [9], "#h": h, "#e": e}), + ), + ( + "fallback #h", + serde_json::json!({"kinds": [9], "#h": h, "#e": e, "#t": ["x"]}), + ), + ("fast no-#h", serde_json::json!({"kinds": [9], "#e": e})), + ( + "fallback no-#h", + serde_json::json!({"kinds": [9], "#e": e, "#t": ["x"]}), + ), + ] + } + + fn search_filter(&self) -> serde_json::Value { + serde_json::json!({"kinds": [9], "#h": [&self.channel], "search": "needle"}) + } + + async fn http(&self, uri: &str, filter: &serde_json::Value) -> (StatusCode, Value) { + use axum::body::Body; + use axum::http::{header, Request}; + use tower::ServiceExt; + let resp = crate::router::build_router(self.state.clone()) + .oneshot( + Request::builder() + .method("POST") + .uri(uri) + .header(header::HOST, &self.host) + .header("x-pubkey", self.reader.public_key().to_hex()) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&[filter]).expect("json"))) + .expect("request"), + ) + .await + .expect("oneshot"); + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("body"); + ( + status, + serde_json::from_slice(&bytes).unwrap_or(Value::Null), + ) + } + + /// Drive a WS frame handler and return the first frame it sends. + async fn ws(&self, drive: F) -> String + where + F: FnOnce(Arc) -> Fut, + Fut: std::future::Future, + { + let (conn, mut send_rx) = self.ws_conn(); + drive(conn).await; + match send_rx.try_recv().expect("handler sent a frame") { + axum::extract::ws::Message::Text(t) => t.to_string(), + other => panic!("expected text frame, got {other:?}"), + } + } + + /// An authenticated reader connection and its outbound frame queue. + fn ws_conn( + &self, + ) -> ( + Arc, + tokio::sync::mpsc::Receiver, + ) { + let (send_tx, send_rx) = tokio::sync::mpsc::channel(64); + let (ctrl_tx, _ctrl_rx) = tokio::sync::mpsc::channel(4); + let conn = Arc::new(crate::connection::ConnectionState { + conn_id: uuid::Uuid::new_v4(), + tenant: TenantContext::resolved(self.community, self.host.clone()), + remote_addr: "127.0.0.1:1234".parse().expect("addr"), + auth_state: std::sync::Mutex::new(crate::connection::AuthState::Authenticated( + buzz_auth::AuthContext { + pubkey: self.reader.public_key(), + scopes: Vec::new(), + channel_ids: None, + auth_method: buzz_auth::AuthMethod::Nip42, + agent_owner_pubkey: None, + }, + )), + subscriptions: Arc::new(tokio::sync::Mutex::new(Default::default())), + send_tx, + ctrl_tx, + cancel: tokio_util::sync::CancellationToken::new(), + backpressure_count: Arc::new(std::sync::atomic::AtomicU8::new(0)), + grace_limit: 3, + }); + (conn, send_rx) + } + + async fn ws_count(&self, filter: &serde_json::Value) -> String { + let filter: nostr::Filter = serde_json::from_value(filter.clone()).expect("filter"); + let state = self.state.clone(); + self.ws(|conn| { + crate::handlers::count::handle_count("c".into(), vec![filter], conn, state) + }) + .await + } + + async fn ws_search(&self) -> String { + let filter: nostr::Filter = + serde_json::from_value(self.search_filter()).expect("filter"); + let state = self.state.clone(); + self.ws(|conn| { + crate::handlers::req::handle_req("s".into(), vec![filter], vec![None], conn, state) + }) + .await + } + + async fn drop(self) { + drop(self.state); + for (pool, name) in [ + (self.writer, &self.names[0]), + (self.replica, &self.names[1]), + ] { + pool.close().await; + let _ = sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP DATABASE IF EXISTS {name} WITH (FORCE)" + ))) + .execute(&self.admin) + .await; + } + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn routed_read_cancel_maps_to_timeout_contract_on_count_and_search() { + let fx = cancel_fixture().await; + + // Healthy controls: every arm and search hydrate serve before the lock. + for (arm, filter) in fx.count_cases() { + let (status, body) = fx.http("/count", &filter).await; + assert_eq!(status, StatusCode::OK, "healthy HTTP COUNT {arm}: {body}"); + let frame = fx.ws_count(&filter).await; + assert!( + frame.starts_with(r#"["COUNT""#), + "healthy WS COUNT {arm}: {frame}" + ); + } + let (status, body) = fx.http("/query", &fx.search_filter()).await; + assert_eq!(status, StatusCode::OK, "healthy HTTP search: {body}"); + assert!( + body.to_string().contains("root needle"), + "search hit: {body}" + ); + let frame = fx.ws_search().await; + assert!(frame.contains("root needle"), "healthy WS search: {frame}"); + + let mut locker = fx.replica.begin().await.expect("begin locker"); + sqlx::query("LOCK TABLE events IN ACCESS EXCLUSIVE MODE") + .execute(&mut *locker) + .await + .expect("lock replica events"); + + let closed = |sub: &str| { + format!( + r#"["CLOSED","{sub}","{}"]"#, + crate::handlers::req::QUERY_TIMED_OUT_CLOSED + ) + }; + for (arm, filter) in fx.count_cases() { + let (status, body) = fx.http("/count", &filter).await; + assert_eq!( + (status, body["error"].as_str()), + ( + StatusCode::SERVICE_UNAVAILABLE, + Some(super::super::QUERY_TIMED_OUT) + ), + "HTTP COUNT {arm}" + ); + assert_eq!(fx.ws_count(&filter).await, closed("c"), "WS COUNT {arm}"); + } + let (status, body) = fx.http("/query", &fx.search_filter()).await; + assert_eq!( + (status, body["error"].as_str()), + ( + StatusCode::SERVICE_UNAVAILABLE, + Some(super::super::QUERY_TIMED_OUT) + ), + "HTTP search hydrate" + ); + assert_eq!(fx.ws_search().await, closed("s"), "WS search hydrate"); + locker.rollback().await.expect("unlock"); + + // Ordinary-error controls: `events` gone on both pools → 42P01 on the + // replica, writer re-run, 42P01 again. Not a timeout. + for pool in [&fx.writer, &fx.replica] { + sqlx::query("ALTER TABLE events RENAME TO events_gone") + .execute(pool) + .await + .expect("rename events"); + } + let (arm, filter) = fx.count_cases().swap_remove(0); + let (status, body) = fx.http("/count", &filter).await; + assert_eq!( + (status, body["error"].as_str()), + ( + StatusCode::INTERNAL_SERVER_ERROR, + Some("internal server error") + ), + "HTTP COUNT {arm} ordinary error" + ); + let frame = fx.ws_count(&filter).await; + assert!( + frame.starts_with(r#"["CLOSED","c","error: "#) && !frame.contains("query timed out"), + "WS COUNT {arm} ordinary error: {frame}" + ); + + fx.drop().await; + } + + /// A search REQ reusing a live subscription's ID retires it (NIP-01 + /// replacement) before its hydrate is cancelled, so the timeout CLOSED + /// leaves nothing registered under the ID. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn search_reusing_live_id_retires_it_before_timeout_closed() { + use crate::handlers::req::handle_req; + let fx = cancel_fixture().await; + let (conn, mut rx) = fx.ws_conn(); + let frames = |rx: &mut tokio::sync::mpsc::Receiver| { + std::iter::from_fn(|| rx.try_recv().ok()) + .map(|msg| match msg { + axum::extract::ws::Message::Text(t) => t.to_string(), + other => panic!("expected text frame, got {other:?}"), + }) + .collect::>() + }; + let channel: uuid::Uuid = fx.channel.parse().expect("channel uuid"); + let topic = buzz_pubsub::EventTopic::Channel(channel); + let tenant = TenantContext::resolved(fx.community, fx.host.clone()); + let live: nostr::Filter = + serde_json::from_value(serde_json::json!({"kinds": [9], "#h": [&fx.channel]})) + .expect("live filter"); + handle_req( + "x".into(), + vec![live], + vec![None], + conn.clone(), + fx.state.clone(), + ) + .await; + assert!( + frames(&mut rx) + .last() + .is_some_and(|f| f == r#"["EOSE","x"]"#), + "live x served" + ); + assert!(conn.subscriptions.lock().await.contains_key("x")); + assert!(fx + .state + .sub_registry + .get_filters(conn.conn_id, "x") + .is_some()); + assert_eq!(fx.state.pubsub.topic_refcount(&tenant, topic).await, 1); + + let mut locker = fx.replica.begin().await.expect("begin locker"); + sqlx::query("LOCK TABLE events IN ACCESS EXCLUSIVE MODE") + .execute(&mut *locker) + .await + .expect("lock replica events"); + let search: nostr::Filter = + serde_json::from_value(fx.search_filter()).expect("search filter"); + let live_owner = *conn.subscriptions.lock().await.get("x").expect("x owned"); + let task = tokio::spawn(handle_req( + "x".into(), + vec![search], + vec![None], + conn.clone(), + fx.state.clone(), + )); + // Once search has claimed `x` its hydrate blocks on the lock. The old + // live fan-out must already be retired then, not only at final cleanup. + while conn.subscriptions.lock().await.get("x") == Some(&live_owner) { + tokio::task::yield_now().await; + } + assert!( + fx.state + .sub_registry + .get_filters(conn.conn_id, "x") + .is_none(), + "accepting search must retire live fan-out" + ); + assert_eq!(fx.state.pubsub.topic_refcount(&tenant, topic).await, 0); + task.await.expect("search task"); + locker.rollback().await.expect("unlock"); + + assert_eq!( + frames(&mut rx), + vec![format!( + r#"["CLOSED","x","{}"]"#, + crate::handlers::req::QUERY_TIMED_OUT_CLOSED + )] + ); + assert!(conn.subscriptions.lock().await.is_empty(), "conn map"); + assert!( + fx.state + .sub_registry + .get_filters(conn.conn_id, "x") + .is_none(), + "fan-out registration must be retired" + ); + assert_eq!(fx.state.pubsub.topic_refcount(&tenant, topic).await, 0); + + drop(conn); + fx.drop().await; + } } diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index c84192a6e6..460f7f70b8 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -27,6 +27,24 @@ pub(crate) fn internal_error(msg: &str) -> (StatusCode, Json) api_error(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") } +/// Stable client-visible body for a read cancelled by its server-side +/// statement deadline. Clients match this string to skip retrying: a retry +/// would re-run the same expensive query. +pub(crate) const QUERY_TIMED_OUT: &str = "query timed out"; + +/// Map a DB read failure: a statement-deadline cancel becomes a distinct +/// 503 `query timed out`; anything else stays a generic 500. +pub(crate) fn db_read_error( + context: &str, + e: &buzz_db::DbError, +) -> (StatusCode, Json) { + if e.is_statement_cancelled() { + tracing::warn!("{context}: {e}"); + return api_error(StatusCode::SERVICE_UNAVAILABLE, QUERY_TIMED_OUT); + } + internal_error(&format!("{context}: {e}")) +} + #[allow(dead_code)] pub(crate) fn not_found(msg: &str) -> (StatusCode, Json) { api_error(StatusCode::NOT_FOUND, msg) @@ -406,6 +424,46 @@ pub mod relay_members { } } +#[cfg(test)] +mod db_read_error_tests { + use super::*; + + #[test] + fn non_cancel_db_error_stays_generic_500() { + let (status, body) = db_read_error("ctx", &buzz_db::DbError::NotFound("x".into())); + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(body.0["error"], "internal server error"); + } +} + +#[cfg(test)] +mod postgres_tests { + use super::*; + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn statement_timeout_maps_to_distinct_503() { + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect to test DB"); + let mut tx = pool.begin().await.expect("begin"); + sqlx::query("SET LOCAL statement_timeout = '10ms'") + .execute(&mut *tx) + .await + .expect("set timeout"); + let err: buzz_db::DbError = sqlx::query("SELECT pg_sleep(1)") + .execute(&mut *tx) + .await + .expect_err("statement must be cancelled") + .into(); + assert!(err.is_statement_cancelled()); + + let (status, body) = db_read_error("ctx", &err); + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body.0["error"], QUERY_TIMED_OUT); + } +} + // ── parse_query_or_400 regression tests ────────────────────────────────────── #[cfg(test)] diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index 12a450861e..63060fe0be 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -16,7 +16,6 @@ use uuid::Uuid; use buzz_auth::{generate_challenge, AuthContext}; use buzz_core::tenant::TenantContext; -use nostr::Filter; use crate::handlers; use crate::metrics::AuthOutcome; @@ -34,8 +33,9 @@ const AUTH_TIMEOUT: Duration = Duration::from_secs(5); /// This stays well inside the process-wide 30-second hard drain. const WS_TERMINAL_FLUSH_TIMEOUT: Duration = Duration::from_secs(1); -/// Shared mutable subscription map for a single WebSocket connection. -pub(crate) type ConnectionSubscriptions = Arc>>>; +/// Shared mutable subscription map for a single WebSocket connection: sub ID → +/// owner token of the request that currently holds it (see `close::next_owner`). +pub(crate) type ConnectionSubscriptions = Arc>>; /// Request for the writer to flush a restart close and report the result. pub(crate) struct RestartClose { @@ -447,20 +447,7 @@ async fn handle_active_connection( let _ = auth_timeout_task.await; let _ = auth_cancel_task.await; - for removed in state.sub_registry.remove_connection(conn.conn_id) { - if removed.scope.is_global() { - state - .pubsub - .release_topic(&conn.tenant, buzz_pubsub::EventTopic::Global) - .await; - } - for &channel_id in removed.scope.channel_ids() { - state - .pubsub - .release_topic(&conn.tenant, buzz_pubsub::EventTopic::Channel(channel_id)) - .await; - } - } + crate::handlers::close::release_connection_subscriptions(&conn, &state).await; state.conn_manager.deregister(conn.conn_id); if let Some(auth_ctx) = authenticated { let remaining = state.conn_manager.connection_ids_for_pubkey_in_community( diff --git a/crates/buzz-relay/src/handlers/close.rs b/crates/buzz-relay/src/handlers/close.rs index 86f3d0da79..0fdb4dd167 100644 --- a/crates/buzz-relay/src/handlers/close.rs +++ b/crates/buzz-relay/src/handlers/close.rs @@ -1,35 +1,137 @@ +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; +use buzz_core::tenant::TenantContext; +use buzz_pubsub::EventTopic; + use tracing::debug; use crate::connection::ConnectionState; use crate::protocol::RelayMessage; use crate::state::AppState; +use crate::subscription::SubscriptionScope; /// Handle a CLOSE command — remove the subscription and send CLOSED acknowledgement. pub async fn handle_close(sub_id: String, conn: Arc, state: Arc) { - let conn_id = conn.conn_id; + // Client CLOSE targets whatever currently holds the ID, so it is + // unconditional. Retiring before CLOSED means nothing is routed to this + // sub after the acknowledgement. + let mut subs = conn.subscriptions.lock().await; + retire_locked(&mut subs, &sub_id, &conn, &state).await; + conn.send(RelayMessage::closed(&sub_id, "")); + drop(subs); - conn.subscriptions.lock().await.remove(&sub_id); + debug!(conn_id = %conn.conn_id, sub_id = %sub_id, "Subscription closed"); +} - // Deregister from the fan-out index before sending CLOSED so no new - // messages are routed to this sub after the client's CLOSE is acknowledged. - if let Some(removed) = state.sub_registry.remove_subscription(conn_id, &sub_id) { - if removed.scope.is_global() { - state - .pubsub - .release_topic(&conn.tenant, buzz_pubsub::EventTopic::Global) - .await; - } - for &channel_id in removed.scope.channel_ids() { - state - .pubsub - .release_topic(&conn.tenant, buzz_pubsub::EventTopic::Channel(channel_id)) - .await; +/// Monotonic source of subscription owner tokens. Each REQ claims its sub ID +/// with a fresh token, so a superseded request can tell it no longer owns it. +static NEXT_OWNER: AtomicU64 = AtomicU64::new(1); + +pub(crate) fn next_owner() -> u64 { + NEXT_OWNER.fetch_add(1, Ordering::Relaxed) +} + +// Lifecycle lock: `conn.subscriptions` serializes every change to a +// connection's subscription state — map token, registry entry, topic +// retention, and the terminal CLOSED frame. Neither the registry nor pubsub +// takes it, so holding it across their awaits cannot invert lock order. + +/// Drop `sub_id` from the map, the fan-out index, and its topic retention. +/// The caller holds the lifecycle lock (`subs`). +pub(crate) async fn retire_locked( + subs: &mut HashMap, + sub_id: &str, + conn: &ConnectionState, + state: &AppState, +) { + subs.remove(sub_id); + if let Some(removed) = state.sub_registry.remove_subscription(conn.conn_id, sub_id) { + release_scope_topics(state, &conn.tenant, &removed.scope).await; + } +} + +/// Retire `sub_id` only if the request holding `owner` still owns it, then +/// send `closed` (if any) before releasing the lifecycle lock, so a replacing +/// REQ can never claim the ID between the teardown and its terminal frame. +/// Returns whether this request still owned the ID. A superseded request +/// touches nothing and says nothing. +pub(crate) async fn close_if_owner( + sub_id: &str, + owner: u64, + closed: Option<&str>, + conn: &ConnectionState, + state: &AppState, +) -> bool { + let mut subs = conn.subscriptions.lock().await; + if subs.get(sub_id) != Some(&owner) { + return false; + } + retire_locked(&mut subs, sub_id, conn, state).await; + #[cfg(test)] + test_seam::pause().await; + if let Some(reason) = closed { + if !conn.send(RelayMessage::closed(sub_id, reason)) { + // The outbound channel is full or closed: the terminal frame is + // lost. Cancel the connection so the subscription is not silently + // orphaned — the client will reconnect and resubscribe. + conn.cancel.cancel(); + } + } + true +} + +/// Final subscription cleanup for a closed connection. Its cancellation token +/// must already be cancelled: claims check it under the lifecycle lock, so no +/// detached REQ task can register after this runs. +pub(crate) async fn release_connection_subscriptions(conn: &ConnectionState, state: &AppState) { + debug_assert!(conn.cancel.is_cancelled()); + let mut subs = conn.subscriptions.lock().await; + subs.clear(); + for removed in state.sub_registry.remove_connection(conn.conn_id) { + release_scope_topics(state, &conn.tenant, &removed.scope).await; + } +} + +/// Release the pubsub topics a subscription scope retained. +pub(crate) async fn release_scope_topics( + state: &AppState, + tenant: &TenantContext, + scope: &SubscriptionScope, +) { + if scope.is_global() { + state.pubsub.release_topic(tenant, EventTopic::Global).await; + } + for &channel_id in scope.channel_ids() { + state + .pubsub + .release_topic(tenant, EventTopic::Channel(channel_id)) + .await; + } +} + +/// Test-only pause point inside `close_if_owner`, between teardown and the +/// terminal frame, scoped to one task so parallel tests don't interfere. +#[cfg(test)] +pub(crate) mod test_seam { + use std::sync::Arc; + use tokio::sync::Notify; + + #[derive(Default)] + pub(crate) struct Pause { + pub(crate) reached: Notify, + pub(crate) resume: Notify, + } + + tokio::task_local! { + pub(crate) static PAUSE: Arc; + } + + pub(super) async fn pause() { + if let Ok(pause) = PAUSE.try_with(Arc::clone) { + pause.reached.notify_one(); + pause.resume.notified().await; } } - - conn.send(RelayMessage::closed(&sub_id, "")); - - debug!(conn_id = %conn_id, sub_id = %sub_id, "Subscription closed"); } diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index 5ea30b238b..50a77e42ef 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -198,7 +198,10 @@ pub async fn handle_count( match state.db.count_events_routed("count_req", &query).await { Ok(n) => total += n as u64, Err(e) => { - conn.send(RelayMessage::closed(&sub_id, &format!("error: {e}"))); + conn.send(RelayMessage::closed( + &sub_id, + &super::req::db_read_closed_reason(&e), + )); return; } } @@ -232,7 +235,10 @@ pub async fn handle_count( } } Err(e) => { - conn.send(RelayMessage::closed(&sub_id, &format!("error: {e}"))); + conn.send(RelayMessage::closed( + &sub_id, + &super::req::db_read_closed_reason(&e), + )); return; } } @@ -272,7 +278,10 @@ pub async fn handle_count( match state.db.count_events_routed("count_req", &query).await { Ok(n) => total += n as u64, Err(e) => { - conn.send(RelayMessage::closed(&sub_id, &format!("error: {e}"))); + conn.send(RelayMessage::closed( + &sub_id, + &super::req::db_read_closed_reason(&e), + )); return; } } @@ -305,7 +314,10 @@ pub async fn handle_count( } } Err(e) => { - conn.send(RelayMessage::closed(&sub_id, &format!("error: {e}"))); + conn.send(RelayMessage::closed( + &sub_id, + &super::req::db_read_closed_reason(&e), + )); return; } } diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 97d252cb7e..a428f1e821 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -267,8 +267,12 @@ pub async fn handle_req( )); return; } + let Some(owner) = claim_search_subscription(&sub_id, &conn, &state).await else { + return; + }; handle_search_req( &sub_id, + owner, &filters, &accessible_channels, token_channel_ids.is_none(), @@ -279,47 +283,22 @@ pub async fn handle_req( trace_state.as_ref(), ) .await; + // One-shot: drop the claim unless a newer REQ already took the ID. + super::close::close_if_owner(&sub_id, owner, None, &conn, &state).await; return; } - { - let mut subs = conn.subscriptions.lock().await; - subs.insert(sub_id.clone(), filters.clone()); - } - - let replaced = if let Some(channel_ids) = authorized_requested_channels.as_ref() { - state.sub_registry.register_channels_scoped( - conn.tenant.community(), - conn_id, - sub_id.clone(), - filters.clone(), - channel_ids.clone(), - ) - } else { - state.sub_registry.register_scoped( - conn.tenant.community(), - conn_id, - sub_id.clone(), - filters.clone(), - None, - ) + let Some(owner) = claim_live_subscription( + &sub_id, + &filters, + authorized_requested_channels.as_deref(), + &conn, + &state, + ) + .await + else { + return; }; - if let Some(replaced) = replaced { - release_subscription_topics(&state, &conn.tenant, &replaced.scope).await; - } - if let Some(channel_ids) = authorized_requested_channels.as_ref() { - for &channel_id in channel_ids { - state - .pubsub - .retain_topic(&conn.tenant, EventTopic::Channel(channel_id)) - .await; - } - } else { - state - .pubsub - .retain_topic(&conn.tenant, EventTopic::Global) - .await; - } debug!(conn_id = %conn_id, sub_id = %sub_id, "Subscription registered"); @@ -396,7 +375,11 @@ pub async fn handle_req( Ok(evs) => evs, Err(e) => { warn!(conn_id = %conn_id, sub_id = %sub_id, "Historical query failed: {e}"); - conn.send(RelayMessage::eose(&sub_id)); + if e.is_statement_cancelled() { + close_timed_out_subscription(&sub_id, owner, &conn, &state).await; + } else { + conn.send(RelayMessage::eose(&sub_id)); + } return; } }; @@ -597,6 +580,7 @@ pub(crate) fn build_search_channel_scope_filter( #[allow(clippy::too_many_arguments)] async fn handle_search_req( sub_id: &str, + owner: u64, filters: &[Filter], accessible_channels: &[uuid::Uuid], include_global: bool, @@ -724,6 +708,12 @@ async fn handle_search_req( .await { Ok(evs) => evs, + Err(e) if e.is_statement_cancelled() => { + // CLOSED (not EOSE) keeps partial results from reading as complete. + warn!(sub_id = %sub_id, "NIP-50 batch fetch timed out: {e}"); + close_timed_out_subscription(sub_id, owner, conn, state).await; + return; + } Err(e) => { warn!(sub_id = %sub_id, "NIP-50 batch fetch failed: {e}"); break; @@ -1269,21 +1259,107 @@ async fn handle_huddle_liveness_req( conn.send(RelayMessage::eose(sub_id)); } -async fn release_subscription_topics( +/// Claim `sub_id` for a live REQ under the lifecycle lock: record a fresh +/// owner token, register for fan-out (replacing any same-ID entry), retain its +/// topics, and release the replaced scope's. Returns the owner token, or +/// `None` once the connection is closing — the caller then exits silently. +async fn claim_live_subscription( + sub_id: &str, + filters: &[Filter], + channel_ids: Option<&[uuid::Uuid]>, + conn: &ConnectionState, state: &AppState, - tenant: &TenantContext, - scope: &crate::subscription::SubscriptionScope, -) { - if scope.is_global() { - state.pubsub.release_topic(tenant, EventTopic::Global).await; - } else { - for &channel_id in scope.channel_ids() { +) -> Option { + let mut subs = conn.subscriptions.lock().await; + if conn.cancel.is_cancelled() { + return None; + } + let owner = super::close::next_owner(); + subs.insert(sub_id.to_string(), owner); + let community = conn.tenant.community(); + let replaced = match channel_ids { + Some(ids) => state.sub_registry.register_channels_scoped( + community, + conn.conn_id, + sub_id.to_string(), + filters.to_vec(), + ids.to_vec(), + ), + None => state.sub_registry.register_scoped( + community, + conn.conn_id, + sub_id.to_string(), + filters.to_vec(), + None, + ), + }; + match channel_ids { + Some(ids) => { + for &channel_id in ids { + state + .pubsub + .retain_topic(&conn.tenant, EventTopic::Channel(channel_id)) + .await; + } + } + None => { state .pubsub - .release_topic(tenant, EventTopic::Channel(channel_id)) + .retain_topic(&conn.tenant, EventTopic::Global) .await; } } + if let Some(replaced) = replaced { + super::close::release_scope_topics(state, &conn.tenant, &replaced.scope).await; + } + Some(owner) +} + +/// Claim `sub_id` for a one-shot search REQ under the lifecycle lock. +/// Accepting it retires whatever holds the ID (NIP-01 replacement), even +/// though search never registers for fan-out. `None` once the connection is +/// closing. +async fn claim_search_subscription( + sub_id: &str, + conn: &ConnectionState, + state: &AppState, +) -> Option { + let mut subs = conn.subscriptions.lock().await; + if conn.cancel.is_cancelled() { + return None; + } + super::close::retire_locked(&mut subs, sub_id, conn, state).await; + let owner = super::close::next_owner(); + subs.insert(sub_id.to_string(), owner); + Some(owner) +} + +/// A read hit the server statement deadline. Sends `CLOSED` rather than `EOSE` +/// so clients don't treat the empty result as complete. CLOSED means the relay +/// dropped the sub (NIP-01) and clients won't `CLOSE` it, so it is retired in +/// the same lifecycle-lock critical section. A request a newer same-ID REQ +/// superseded owns nothing and says nothing. +async fn close_timed_out_subscription( + sub_id: &str, + owner: u64, + conn: &ConnectionState, + state: &AppState, +) { + super::close::close_if_owner(sub_id, owner, Some(QUERY_TIMED_OUT_CLOSED), conn, state).await; +} + +/// Stable CLOSED reason for a read cancelled by its server statement deadline. +/// Clients match it to skip retrying the same expensive query. +pub(crate) const QUERY_TIMED_OUT_CLOSED: &str = "error: query timed out"; + +/// CLOSED reason for a failed one-shot DB read (COUNT): a statement cancel gets +/// the stable timeout reason; anything else keeps the raw error. +pub(crate) fn db_read_closed_reason(e: &buzz_db::DbError) -> String { + if e.is_statement_cancelled() { + QUERY_TIMED_OUT_CLOSED.to_string() + } else { + format!("error: {e}") + } } fn extract_channel_id_from_filters(filters: &[Filter]) -> Option { @@ -1554,6 +1630,491 @@ mod tests { use super::*; use nostr::{Alphabet, Filter, SingleLetterTag}; + fn lifecycle_conn() -> ( + ConnectionState, + tokio::sync::mpsc::Receiver, + ) { + use std::sync::atomic::AtomicU8; + let (send_tx, send_rx) = tokio::sync::mpsc::channel(8); + let (ctrl_tx, _ctrl_rx) = tokio::sync::mpsc::channel(1); + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); + let conn = ConnectionState { + conn_id: uuid::Uuid::new_v4(), + tenant: buzz_core::tenant::TenantContext::resolved(community, "t.local".to_string()), + remote_addr: "127.0.0.1:1234".parse().expect("addr"), + auth_state: std::sync::Mutex::new(AuthState::Failed), + subscriptions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + send_tx, + ctrl_tx, + cancel: tokio_util::sync::CancellationToken::new(), + backpressure_count: Arc::new(AtomicU8::new(0)), + grace_limit: 3, + }; + (conn, send_rx) + } + + fn text_filters() -> Vec { + vec![Filter::new().kind(nostr::Kind::TextNote)] + } + + fn registered(state: &AppState, conn: &ConnectionState, sub_id: &str) -> bool { + state + .sub_registry + .get_filters(conn.conn_id, sub_id) + .is_some() + } + + fn closed_frames( + rx: &mut tokio::sync::mpsc::Receiver, + ) -> Vec { + std::iter::from_fn(|| rx.try_recv().ok()) + .map(|msg| match msg { + axum::extract::ws::Message::Text(t) => t.to_string(), + other => panic!("expected text frame, got {other:?}"), + }) + .collect() + } + + #[tokio::test] + async fn timed_out_historical_read_deregisters_before_closed() { + let state = crate::state::tests::test_state().await; + let (conn, mut send_rx) = lifecycle_conn(); + let channel = uuid::Uuid::new_v4(); + let topic = EventTopic::Channel(channel); + let owner = + claim_live_subscription("thread", &text_filters(), Some(&[channel]), &conn, &state) + .await + .expect("claim"); + assert_eq!(state.pubsub.topic_refcount(&conn.tenant, topic).await, 1); + + close_timed_out_subscription("thread", owner, &conn, &state).await; + + assert!(conn.subscriptions.lock().await.is_empty()); + assert!( + !registered(&state, &conn, "thread"), + "fan-out registration must be gone" + ); + assert_eq!( + state.pubsub.topic_refcount(&conn.tenant, topic).await, + 0, + "retained topic must be released" + ); + assert_eq!( + closed_frames(&mut send_rx), + vec![format!(r#"["CLOSED","thread","{QUERY_TIMED_OUT_CLOSED}"]"#)] + ); + } + + /// A superseded REQ's late timeout must not tear down, or send a terminal + /// CLOSED for, the same-ID replacement that now owns the ID. + #[tokio::test] + async fn superseded_timeout_leaves_replacement_intact() { + let state = crate::state::tests::test_state().await; + let (conn, mut send_rx) = lifecycle_conn(); + let (old_channel, new_channel) = (uuid::Uuid::new_v4(), uuid::Uuid::new_v4()); + let (old_topic, new_topic) = ( + EventTopic::Channel(old_channel), + EventTopic::Channel(new_channel), + ); + let old = + claim_live_subscription("x", &text_filters(), Some(&[old_channel]), &conn, &state) + .await + .expect("claim"); + let new = + claim_live_subscription("x", &text_filters(), Some(&[new_channel]), &conn, &state) + .await + .expect("claim"); + assert_eq!( + state.pubsub.topic_refcount(&conn.tenant, old_topic).await, + 0 + ); + + close_timed_out_subscription("x", old, &conn, &state).await; + + assert_eq!(conn.subscriptions.lock().await.get("x"), Some(&new)); + assert!( + registered(&state, &conn, "x"), + "replacement must stay registered" + ); + assert_eq!( + state.pubsub.topic_refcount(&conn.tenant, new_topic).await, + 1, + "replacement topic must stay retained" + ); + assert!( + closed_frames(&mut send_rx).is_empty(), + "superseded request must say nothing" + ); + } + + /// Accepting a search REQ retires the live subscription holding its ID, and + /// a search that a newer live REQ supersedes cannot close that REQ. + #[tokio::test] + async fn search_claim_retires_live_and_yields_to_replacement() { + let state = crate::state::tests::test_state().await; + let (conn, mut send_rx) = lifecycle_conn(); + let channel = uuid::Uuid::new_v4(); + let topic = EventTopic::Channel(channel); + claim_live_subscription("x", &text_filters(), Some(&[channel]), &conn, &state) + .await + .expect("claim"); + + let search = claim_search_subscription("x", &conn, &state) + .await + .expect("claim"); + assert!( + !registered(&state, &conn, "x"), + "search must retire live fan-out" + ); + assert_eq!(state.pubsub.topic_refcount(&conn.tenant, topic).await, 0); + + let live = claim_live_subscription("x", &text_filters(), Some(&[channel]), &conn, &state) + .await + .expect("claim"); + close_timed_out_subscription("x", search, &conn, &state).await; + + assert_eq!(conn.subscriptions.lock().await.get("x"), Some(&live)); + assert!(registered(&state, &conn, "x")); + assert_eq!(state.pubsub.topic_refcount(&conn.tenant, topic).await, 1); + assert!(closed_frames(&mut send_rx).is_empty()); + } + + /// Concurrent same-ID claims and stale teardowns: whichever claim lands + /// last owns the ID, and every stale teardown is a silent no-op. + #[tokio::test] + async fn concurrent_claims_and_stale_teardowns_keep_the_last_owner() { + let state = crate::state::tests::test_state().await; + let (conn, mut send_rx) = lifecycle_conn(); + let conn = Arc::new(conn); + let channel = uuid::Uuid::new_v4(); + let topic = EventTopic::Channel(channel); + let stale = claim_live_subscription("x", &text_filters(), Some(&[channel]), &conn, &state) + .await + .expect("claim"); + + let tasks = (0..16).map(|i| { + let (conn, state) = (Arc::clone(&conn), Arc::clone(&state)); + tokio::spawn(async move { + if i % 2 == 0 { + claim_live_subscription("x", &text_filters(), Some(&[channel]), &conn, &state) + .await + .expect("claim"); + } else { + close_timed_out_subscription("x", stale, &conn, &state).await; + } + }) + }); + for task in tasks.collect::>() { + task.await.expect("task"); + } + + let owner = *conn.subscriptions.lock().await.get("x").expect("x owned"); + assert_ne!(owner, stale); + assert!(registered(&state, &conn, "x")); + assert_eq!(state.pubsub.topic_refcount(&conn.tenant, topic).await, 1); + close_timed_out_subscription("x", owner, &conn, &state).await; + assert_eq!(state.pubsub.topic_refcount(&conn.tenant, topic).await, 0); + assert_eq!( + closed_frames(&mut send_rx), + vec![format!(r#"["CLOSED","x","{QUERY_TIMED_OUT_CLOSED}"]"#)], + "only the final owner's teardown may close" + ); + } + + fn subscribed(state: &AppState, conn: &ConnectionState, channel: uuid::Uuid) -> bool { + state + .sub_registry + .channel_subscriber_conns_scoped(conn.tenant.community(), channel) + .contains(&conn.conn_id) + } + + /// Poll `fut` exactly once and require it to park. A contender for a held + /// tokio mutex enqueues its waiter and returns `Pending` on that poll, so + /// this proves it reached its lock attempt without relying on scheduling. + async fn assert_parked(fut: &mut F, what: &str) { + let parked = std::future::poll_fn(|cx| { + std::task::Poll::Ready(std::pin::Pin::new(&mut *fut).poll(cx).is_pending()) + }) + .await; + assert!(parked, "{what} must be waiting on the lifecycle lock"); + } + + /// The owner's teardown and its terminal CLOSED are one critical section: + /// a replacement claiming meanwhile waits, so it can never be the target of + /// the old request's CLOSED. + #[tokio::test] + async fn timeout_closed_is_emitted_before_a_replacement_can_claim() { + use super::super::close::test_seam::{Pause, PAUSE}; + let state = crate::state::tests::test_state().await; + let (conn, mut send_rx) = lifecycle_conn(); + let conn = Arc::new(conn); + let (a, b) = (uuid::Uuid::new_v4(), uuid::Uuid::new_v4()); + let old = claim_live_subscription("x", &text_filters(), Some(&[a]), &conn, &state) + .await + .expect("claim"); + + let pause = Arc::new(Pause::default()); + let teardown = tokio::spawn(PAUSE.scope(Arc::clone(&pause), { + let (conn, state) = (Arc::clone(&conn), Arc::clone(&state)); + async move { close_timed_out_subscription("x", old, &conn, &state).await } + })); + pause.reached.notified().await; + let (filters, b_scope) = (text_filters(), [b]); + let mut replacement = Box::pin(claim_live_subscription( + "x", + &filters, + Some(&b_scope), + &conn, + &state, + )); + assert_parked(&mut replacement, "replacement").await; + assert!( + !subscribed(&state, &conn, b), + "replacement must not claim between teardown and CLOSED" + ); + assert!(closed_frames(&mut send_rx).is_empty()); + + pause.resume.notify_one(); + teardown.await.expect("teardown"); + let new = replacement.await.expect("claim"); + + assert_eq!( + closed_frames(&mut send_rx), + vec![format!(r#"["CLOSED","x","{QUERY_TIMED_OUT_CLOSED}"]"#)] + ); + assert_eq!(conn.subscriptions.lock().await.get("x"), Some(&new)); + assert!(subscribed(&state, &conn, b) && !subscribed(&state, &conn, a)); + let refcount = |c| { + state + .pubsub + .topic_refcount(&conn.tenant, EventTopic::Channel(c)) + }; + assert_eq!((refcount(a).await, refcount(b).await), (0, 1)); + } + + /// Revoke selects and removes registry entries under the lifecycle lock, so + /// a same-ID replacement queued behind it keeps its token, scope and topic. + #[tokio::test] + async fn revoke_then_replacement_keeps_replacement_whole() { + let state = crate::state::tests::test_state().await; + let (conn, mut send_rx) = lifecycle_conn(); + let conn = Arc::new(conn); + state.conn_manager.register( + conn.conn_id, + conn.send_tx.clone(), + conn.ctrl_tx.clone(), + None, + conn.cancel.clone(), + conn.tenant.community(), + Arc::clone(&conn.backpressure_count), + Arc::clone(&conn.subscriptions), + 3, + ); + let (a, b) = (uuid::Uuid::new_v4(), uuid::Uuid::new_v4()); + claim_live_subscription("x", &text_filters(), Some(&[a]), &conn, &state) + .await + .expect("claim"); + + let guard = conn.subscriptions.lock().await; + let mut revoke = Box::pin( + crate::handlers::side_effects::evict_conn_channel_subscriptions( + &conn.tenant, + &state, + a, + conn.conn_id, + ), + ); + assert_parked(&mut revoke, "revoke").await; + let (filters, b_scope) = (text_filters(), [b]); + let mut replacement = Box::pin(claim_live_subscription( + "x", + &filters, + Some(&b_scope), + &conn, + &state, + )); + assert_parked(&mut replacement, "replacement").await; + assert!( + subscribed(&state, &conn, a), + "revoke must not touch the registry before taking the lifecycle lock" + ); + drop(guard); + // The tokio mutex is FIFO: revoke queued first, so it runs first. + revoke.await; + let new = replacement.await.expect("claim"); + + assert_eq!(conn.subscriptions.lock().await.get("x"), Some(&new)); + assert!(subscribed(&state, &conn, b) && !subscribed(&state, &conn, a)); + let refcount = |c| { + state + .pubsub + .topic_refcount(&conn.tenant, EventTopic::Channel(c)) + }; + assert_eq!((refcount(a).await, refcount(b).await), (0, 1)); + assert_eq!( + closed_frames(&mut send_rx), + vec![r#"["CLOSED","x","restricted: channel access revoked"]"#.to_string()] + ); + state.conn_manager.deregister(conn.conn_id); + } + + /// Connection cleanup fences later claims: a detached REQ task resuming + /// after it gets `None` and leaves nothing behind. + #[tokio::test] + async fn claims_after_connection_cleanup_are_refused() { + let state = crate::state::tests::test_state().await; + let (conn, mut send_rx) = lifecycle_conn(); + let channel = uuid::Uuid::new_v4(); + let topic = EventTopic::Channel(channel); + claim_live_subscription("live", &text_filters(), Some(&[channel]), &conn, &state) + .await + .expect("claim"); + + conn.cancel.cancel(); + super::super::close::release_connection_subscriptions(&conn, &state).await; + let late_live = + claim_live_subscription("late", &text_filters(), Some(&[channel]), &conn, &state).await; + let late_search = claim_search_subscription("search", &conn, &state).await; + + assert_eq!((late_live, late_search), (None, None)); + assert!(conn.subscriptions.lock().await.is_empty()); + for sub in ["live", "late", "search"] { + assert!( + !registered(&state, &conn, sub), + "{sub} must not be registered" + ); + } + assert_eq!(state.pubsub.topic_refcount(&conn.tenant, topic).await, 0); + assert!(closed_frames(&mut send_rx).is_empty()); + } + + /// A terminal CLOSED that fails to enqueue after retirement cancels the + /// connection, so the subscription is never silently orphaned on a + /// congested connection. + /// + /// Regression for P2-2: before this fix, a `false` from `conn.send` was + /// silently ignored after retirement, leaving the subscription retired but + /// the connection alive without a CLOSED delivered to the client. + #[tokio::test] + async fn dropped_terminal_frame_cancels_connection() { + use std::sync::atomic::AtomicU8; + let state = crate::state::tests::test_state().await; + // Capacity-1 channel: one dummy message fills it, so the CLOSED + // `try_send` returns Full (below the grace_limit=3 auto-cancel) and + // the fix's explicit cancel must fire. + let (send_tx, mut send_rx) = tokio::sync::mpsc::channel(1); + let (ctrl_tx, _ctrl_rx) = tokio::sync::mpsc::channel(1); + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); + let conn = Arc::new(ConnectionState { + conn_id: uuid::Uuid::new_v4(), + tenant: buzz_core::tenant::TenantContext::resolved(community, "t.local".to_string()), + remote_addr: "127.0.0.1:1234".parse().expect("addr"), + auth_state: std::sync::Mutex::new(AuthState::Failed), + subscriptions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + send_tx, + ctrl_tx, + cancel: tokio_util::sync::CancellationToken::new(), + backpressure_count: Arc::new(AtomicU8::new(0)), + grace_limit: 3, + }); + + // Claim, then fill the send buffer so the next `send` returns false. + let owner = claim_live_subscription("x", &text_filters(), None, &conn, &state) + .await + .expect("claim"); + // Occupy the one slot so try_send returns Full on the CLOSED. + send_rx.try_recv().ok(); // drain any prior messages + let _ = conn.send_tx.try_send(axum::extract::ws::Message::Text( + "filler".to_string().into(), + )); + assert!(!conn.cancel.is_cancelled(), "must not be cancelled yet"); + + // `close_timed_out_subscription` retires the sub and tries to send CLOSED. + // With the buffer full the frame is dropped and the fix must cancel. + close_timed_out_subscription("x", owner, &conn, &state).await; + + assert!( + conn.cancel.is_cancelled(), + "connection must be cancelled when the terminal frame is dropped" + ); + // The subscription is still retired — the map is empty. + assert!(conn.subscriptions.lock().await.is_empty()); + assert!(!registered(&state, &conn, "x")); + } + + /// A revoke `CLOSED restricted` that fails to enqueue after the map remove + /// cancels the connection, same as the timeout path. + /// + /// Regression for P2-2 (revoke branch): before this fix, a `false` from + /// `send_to` in `evict_conn_channel_subscriptions` was silently ignored + /// after retirement, leaving the subscription orphaned on a congested + /// connection. + #[tokio::test] + async fn revoke_dropped_terminal_frame_cancels_connection() { + use std::sync::atomic::AtomicU8; + let state = crate::state::tests::test_state().await; + // Capacity-1 channel: one dummy message fills it so the CLOSED + // restricted `try_send` returns Full (below grace_limit=3) and the + // fix's explicit cancel_conn must fire. + let (send_tx, mut send_rx) = tokio::sync::mpsc::channel(1); + let (ctrl_tx, _ctrl_rx) = tokio::sync::mpsc::channel(1); + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); + let conn = Arc::new(ConnectionState { + conn_id: uuid::Uuid::new_v4(), + tenant: buzz_core::tenant::TenantContext::resolved(community, "t.local".to_string()), + remote_addr: "127.0.0.1:1234".parse().expect("addr"), + auth_state: std::sync::Mutex::new(AuthState::Failed), + subscriptions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + send_tx: send_tx.clone(), + ctrl_tx, + cancel: tokio_util::sync::CancellationToken::new(), + backpressure_count: Arc::new(AtomicU8::new(0)), + grace_limit: 3, + }); + // Register in conn_manager so evict_conn_channel_subscriptions can + // find the connection for cancel_conn. + state.conn_manager.register( + conn.conn_id, + send_tx, + conn.ctrl_tx.clone(), + None, + conn.cancel.clone(), + conn.tenant.community(), + Arc::clone(&conn.backpressure_count), + Arc::clone(&conn.subscriptions), + 3, + ); + + let channel = uuid::Uuid::new_v4(); + claim_live_subscription("x", &text_filters(), Some(&[channel]), &conn, &state) + .await + .expect("claim"); + + // Fill the one send slot so the CLOSED restricted try_send returns Full. + send_rx.try_recv().ok(); // drain any earlier messages + let _ = conn.send_tx.try_send(axum::extract::ws::Message::Text( + "filler".to_string().into(), + )); + assert!(!conn.cancel.is_cancelled(), "must not be cancelled yet"); + + crate::handlers::side_effects::evict_conn_channel_subscriptions( + &conn.tenant, + &state, + channel, + conn.conn_id, + ) + .await; + + assert!( + conn.cancel.is_cancelled(), + "connection must be cancelled when the revoke terminal frame is dropped" + ); + assert!(conn.subscriptions.lock().await.is_empty()); + assert!(!subscribed(&state, &conn, channel)); + + state.conn_manager.deregister(conn.conn_id); + } + #[test] fn huddle_liveness_filters_require_only_the_snapshot_kind() { let liveness = Filter::new().kind(nostr::Kind::Custom(KIND_HUDDLE_LIVENESS as u16)); diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index f63c174d96..c2131b1a55 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -231,41 +231,42 @@ async fn disable_departed_member_workflows( } /// Close every live channel-scoped subscription on `conn_id`, removing them from -/// the connection's local map and sending `CLOSED restricted` for each. -async fn evict_conn_channel_subscriptions( +/// the connection's local map and sending `CLOSED restricted` for each. Runs +/// under the connection's lifecycle lock so a concurrent same-ID claim is +/// either fully before (and revoked) or fully after (and untouched). +pub(crate) async fn evict_conn_channel_subscriptions( tenant: &TenantContext, state: &Arc, channel_id: Uuid, conn_id: uuid::Uuid, ) { + // No map means the connection already ran its final cleanup, which + // removed its registry entries under this same lock. + let Some(subscriptions) = state.conn_manager.subscriptions_for(conn_id) else { + return; + }; + let mut conn_subscriptions = subscriptions.lock().await; let removed = state.sub_registry.remove_channel_subscriptions_scoped( tenant.community(), conn_id, channel_id, ); - if removed.is_empty() { - return; - } - - if let Some(subscriptions) = state.conn_manager.subscriptions_for(conn_id) { - let mut conn_subscriptions = subscriptions.lock().await; - for update in &removed { - if update.removed { - conn_subscriptions.remove(&update.sub_id); - } - } - } - for update in removed { + // A multi-channel sub keeps its other channels (and its map token). state .pubsub .release_topic(tenant, buzz_pubsub::EventTopic::Channel(channel_id)) .await; if update.removed { - let _ = state.conn_manager.send_to( + conn_subscriptions.remove(&update.sub_id); + if !state.conn_manager.send_to( conn_id, RelayMessage::closed(&update.sub_id, "restricted: channel access revoked"), - ); + ) { + // Terminal frame lost — cancel so the subscription is not + // silently orphaned on a congested connection. + state.conn_manager.cancel_conn(conn_id); + } } } } diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 0bcab5d63a..417e9bb763 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -459,6 +459,14 @@ impl ConnectionManager { .and_then(|entry| entry.authenticated_pubkey.read().ok()?.clone()) } + /// Cancel a single connection by ID. A no-op if the connection is not + /// registered (already deregistered or never known). + pub(crate) fn cancel_conn(&self, conn_id: Uuid) { + if let Some(entry) = self.connections.get(&conn_id) { + entry.cancel.cancel(); + } + } + /// Disconnect every live connection authenticated as `pubkey` **in /// `community`**, delivering a final `OK false` frame carrying `reason` /// before closing. diff --git a/desktop/src-tauri/src/native_relay_client_finite_tests.rs b/desktop/src-tauri/src/native_relay_client_finite_tests.rs index 913d17f5fd..7b236b98ac 100644 --- a/desktop/src-tauri/src/native_relay_client_finite_tests.rs +++ b/desktop/src-tauri/src/native_relay_client_finite_tests.rs @@ -101,6 +101,7 @@ async fn terminal_refusal_and_deadline_do_not_become_empty_success() { for message in [ "restricted: denied", "error: transient", + "error: query timed out", "rate-limited: quota exceeded; retry in 10s", "rate-limited: quota exceeded", "rate-limited: quota exceeded; retry in 18446744073709551615s", diff --git a/desktop/src-tauri/src/native_relay_client_tests.rs b/desktop/src-tauri/src/native_relay_client_tests.rs index f12a689449..ee284be518 100644 --- a/desktop/src-tauri/src/native_relay_client_tests.rs +++ b/desktop/src-tauri/src/native_relay_client_tests.rs @@ -328,6 +328,32 @@ async fn a_closed_subscription_reopens_without_a_desired_set_change() { session.shutdown(); } +/// A statement deadline on a persistent subscription is transient (archive +/// subs are `limit: 0` live tails), so it backs off and reopens rather than +/// latching terminal. Finite requests still fail on it; see +/// `native_relay_client_finite_tests.rs`. +#[tokio::test] +async fn a_deadline_closed_subscription_reopens_after_backoff() { + let (relay_url, mut frames, closed) = stub_relay().await; + let (session, _events) = start(relay_url, Keys::generate(), None).await; + + session.set_subscriptions(vec![probe_subscription()]).await; + assert_eq!(next_req(&mut frames, "the initial REQ").await, PROBE_ID); + settle().await; + + closed + .send(StubCommand::Closed( + PROBE_ID.into(), + "error: query timed out".into(), + )) + .await + .expect("stub relay accepts the closed command"); + + assert_eq!(next_req(&mut frames, "the reopened REQ").await, PROBE_ID); + + session.shutdown(); +} + /// A relay that rejects on policy must not be re-asked in a tight loop. #[tokio::test] async fn a_terminal_closed_is_not_retried_on_the_same_socket() { diff --git a/desktop/src/features/messages/lib/renderScopedReactions.test.mjs b/desktop/src/features/messages/lib/renderScopedReactions.test.mjs index 6429ecf811..37d971b268 100644 --- a/desktop/src/features/messages/lib/renderScopedReactions.test.mjs +++ b/desktop/src/features/messages/lib/renderScopedReactions.test.mjs @@ -174,3 +174,30 @@ test("failed hydration releases ids so the next render can retry", async () => { [messageId], ); }); + +for (const message of [ + "error: query timed out", + "relay returned 503 Service Unavailable: query timed out", +]) { + test(`deadline-failed hydration keeps ids claimed: ${message}`, async () => { + const messageId = hex("1"); + const queryClient = makeQueryClientStub([event(messageId, 9)]); + + await hydrateRenderScopedReactions({ + channelId: CHANNEL_ID, + messageIds: [messageId], + queryClient, + deps: { + fetchReactionEventsForMessages: async () => { + throw new Error(message); + }, + }, + }); + + // Still claimed: the next render must not re-send the same slow read. + assert.deepEqual( + claimUnhydratedRenderScopedReactionIds(CHANNEL_ID, [messageId]), + [], + ); + }); +} diff --git a/desktop/src/features/messages/lib/renderScopedReactions.ts b/desktop/src/features/messages/lib/renderScopedReactions.ts index 2c35cdf79e..e5128770e8 100644 --- a/desktop/src/features/messages/lib/renderScopedReactions.ts +++ b/desktop/src/features/messages/lib/renderScopedReactions.ts @@ -6,6 +6,7 @@ import type { TimelineMessage } from "../types"; import { relayClient } from "@/shared/api/relayClient"; import { buildChannelReactionAuxFilter } from "@/shared/api/relayChannelFilters"; import type { RelayEvent } from "@/shared/api/types"; +import { isQueryDeadlineError } from "@/shared/lib/relayError"; export type RenderScopedReactionDeps = { fetchReactionEventsForMessages: ( @@ -24,9 +25,22 @@ const defaultDeps: RenderScopedReactionDeps = { }; const hydratedMessageIdsByChannel = new Map>(); +// Ids held claimed after a relay deadline, so automatic re-renders skip them +// until the user reopens the channel. +const deadlineClaimedIdsByChannel = new Map>(); export function resetRenderScopedReactionHydration() { hydratedMessageIdsByChannel.clear(); + deadlineClaimedIdsByChannel.clear(); +} + +export function releaseDeadlineClaimedReactionIds(channelId: string) { + const ids = deadlineClaimedIdsByChannel.get(channelId); + if (!ids) { + return; + } + deadlineClaimedIdsByChannel.delete(channelId); + releaseRenderScopedReactionIds(channelId, [...ids]); } function hydratedSetForChannel(channelId: string): Set { @@ -130,7 +144,19 @@ export async function hydrateRenderScopedReactions(input: { (current = []) => sortMessages([...current, ...reactionEvents]), ); } catch (error) { - releaseRenderScopedReactionIds(input.channelId, messageIds); + // Keep deadline-failed ids claimed: releasing them lets the next render + // re-send the same slow `#e` read. Reopening the channel releases them; + // other failures stay retryable immediately. + if (isQueryDeadlineError(error)) { + let held = deadlineClaimedIdsByChannel.get(input.channelId); + if (!held) { + held = new Set(); + deadlineClaimedIdsByChannel.set(input.channelId, held); + } + for (const id of messageIds) held.add(id); + } else { + releaseRenderScopedReactionIds(input.channelId, messageIds); + } console.error( "Failed to hydrate visible reactions for channel", input.channelId, diff --git a/desktop/src/features/messages/lib/useRenderScopedReactionHydration.jsdom-test.mjs b/desktop/src/features/messages/lib/useRenderScopedReactionHydration.jsdom-test.mjs new file mode 100644 index 0000000000..97d3d6d2af --- /dev/null +++ b/desktop/src/features/messages/lib/useRenderScopedReactionHydration.jsdom-test.mjs @@ -0,0 +1,124 @@ +/** + * Reopen recovery for deadline-held reactions, through the production hook. + * + * A reaction fetch that hits the relay deadline keeps its ids claimed so + * automatic re-renders do not re-send the slow read. Reopening the channel is + * the explicit retry: the hook's `channelId` effect releases the held ids. + * + * Falsifiability: deleting the release effect in + * `useRenderScopedReactionHydration.ts` leaves the reopen with no fetch. + */ + +import assert from "node:assert/strict"; +import { afterEach, mock, test } from "node:test"; + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { relayClient } from "@/shared/api/relayClient"; +import { channelMessagesKey } from "./messageQueryKeys.ts"; +import { resetRenderScopedReactionHydration } from "./renderScopedReactions.ts"; +import { useRenderScopedReactionHydration } from "./useRenderScopedReactionHydration.ts"; + +const CHANNEL_A = "36411e44-0e2d-4cfe-bd6e-567eb169db9f"; +const CHANNEL_B = "8a1c3f52-6d0e-4b7a-9c21-0f5e7d3b2a10"; +const MESSAGE_ID = "1".repeat(64); +const REACTION = { + id: "9".repeat(64), + pubkey: "a".repeat(64), + kind: 7, + created_at: 1_700_000_000, + content: "✅", + tags: [["e", MESSAGE_ID]], + sig: "sig", +}; + +afterEach(() => { + mock.restoreAll(); + resetRenderScopedReactionHydration(); +}); + +function Harness({ channelId, entries }) { + useRenderScopedReactionHydration({ + activeChannel: { id: channelId, channelType: "stream" }, + mainTimelineEntries: entries, + threadHeadMessage: null, + threadMessages: [], + }); + return null; +} + +async function flush() { + // Hydration is deferred with window.setTimeout(0). + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); +} + +test("reopening a channel refetches deadline-held reactions; same-channel re-renders do not", async () => { + const requests = []; + let failWithDeadline = true; + mock.method(relayClient, "fetchAuxEventsByReference", async (channelId) => { + requests.push(channelId); + if (channelId === CHANNEL_A && failWithDeadline) { + throw new Error("error: query timed out"); + } + return channelId === CHANNEL_A ? [REACTION] : []; + }); + mock.method(console, "error", () => {}); + + const queryClient = new QueryClient(); + queryClient.setQueryData(channelMessagesKey(CHANNEL_A), []); + const container = document.createElement("div"); + const root = createRoot(container); + const render = (channelId) => + act(() => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement(Harness, { + channelId, + // Fresh array each render, as a live timeline update produces. + entries: [{ message: { id: MESSAGE_ID }, summary: null }], + }), + ), + ); + }); + + await render(CHANNEL_A); + await flush(); + assert.deepEqual( + requests, + [CHANNEL_A], + "first open fetches and hits the deadline", + ); + + failWithDeadline = false; + await render(CHANNEL_A); + await flush(); + assert.deepEqual( + requests, + [CHANNEL_A], + "same-channel re-render keeps ids held", + ); + + await render(CHANNEL_B); + await flush(); + await render(CHANNEL_A); + await flush(); + assert.deepEqual( + requests, + [CHANNEL_A, CHANNEL_B, CHANNEL_A], + "reopening channel A refetches its held ids", + ); + assert.ok( + queryClient + .getQueryData(channelMessagesKey(CHANNEL_A)) + .some((event) => event.id === REACTION.id), + "recovered reaction lands in the channel cache", + ); + + act(() => root.unmount()); +}); diff --git a/desktop/src/features/messages/lib/useRenderScopedReactionHydration.ts b/desktop/src/features/messages/lib/useRenderScopedReactionHydration.ts index 8bc3f7724c..641bf80cde 100644 --- a/desktop/src/features/messages/lib/useRenderScopedReactionHydration.ts +++ b/desktop/src/features/messages/lib/useRenderScopedReactionHydration.ts @@ -7,6 +7,7 @@ import type { Channel } from "@/shared/api/types"; import { collectRenderScopedReactionMessageIds, hydrateRenderScopedReactions, + releaseDeadlineClaimedReactionIds, } from "./renderScopedReactions"; export function useRenderScopedReactionHydration(input: { @@ -16,6 +17,13 @@ export function useRenderScopedReactionHydration(input: { threadMessages: MainTimelineEntry[]; }) { const queryClient = useQueryClient(); + const channelId = input.activeChannel?.id; + + // Opening a channel is the explicit retry for reactions a relay deadline + // held back. Declared first so it runs before this render's hydration. + React.useEffect(() => { + if (channelId) releaseDeadlineClaimedReactionIds(channelId); + }, [channelId]); React.useEffect(() => { const channelId = input.activeChannel?.id; diff --git a/desktop/src/features/messages/queryDeadlineRetry.test.mjs b/desktop/src/features/messages/queryDeadlineRetry.test.mjs new file mode 100644 index 0000000000..c8dd559bfa --- /dev/null +++ b/desktop/src/features/messages/queryDeadlineRetry.test.mjs @@ -0,0 +1,154 @@ +/** + * Deadline-retry contract, exercised under the production QueryClient: + * a read that hit the relay's `query timed out` deadline is fetched once; an + * ordinary failure, including a client-side request timeout, keeps its retries. + * Removing the predicate from `createBuzzQueryClient` or `useThreadReplies` + * makes the deadline cases here fetch more than once. + */ + +import assert from "node:assert/strict"; +import { mock } from "node:test"; +import test from "node:test"; +import { registerHooks } from "node:module"; + +import { JSDOM } from "jsdom"; + +globalThis.__tauriGetThreadReplies = async () => ({ + events: [], + nextCursor: null, +}); + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "@/shared/api/tauri") { + return { shortCircuit: true, url: "buzz-deadline-stub:tauri" }; + } + return nextResolve(specifier, context); + }, + load(url, context, nextLoad) { + if (url === "buzz-deadline-stub:tauri") { + return { + format: "module", + shortCircuit: true, + source: `export async function getThreadReplies(...args) { + return globalThis.__tauriGetThreadReplies(...args); +} +export default {};`, + }; + } + return nextLoad(url, context); + }, +}); + +const dom = new JSDOM("", { + url: "http://localhost", + pretendToBeVisual: true, +}); +dom.window.document.hasFocus = () => true; +Object.assign(globalThis, { + IS_REACT_ACT_ENVIRONMENT: true, + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + window: dom.window, +}); + +const { act, cleanup, renderHook } = await import("@testing-library/react"); +const { createElement } = await import("react"); +const { QueryClientProvider, useQuery } = await import("@tanstack/react-query"); +const { createBuzzQueryClient } = await import("@/shared/api/queryClient"); +const { useThreadReplies, useThreadRepliesForRoots } = await import( + "./useThreadReplies.ts" +); + +const CASES = [ + { + name: "client request timeout", + message: "relay unreachable: request timed out", + deadline: false, + }, + { + name: "relay deadline", + message: "relay returned 503 Service Unavailable: query timed out", + deadline: true, + }, + { + name: "ordinary error", + message: "relay returned 500: internal server error", + deadline: false, + }, +]; + +// Mount `useHook` under the production client, drive every retry delay with +// fake timers until the query settles in error, and return the fetch count. +async function fetchCountUntilError(useHook, isSettled) { + mock.timers.enable({ apis: ["setTimeout"] }); + const queryClient = createBuzzQueryClient(); + const wrapper = ({ children }) => + createElement(QueryClientProvider, { client: queryClient }, children); + try { + const { result, unmount } = renderHook(useHook, { wrapper }); + for (let i = 0; i < 20 && !isSettled(result.current); i++) { + mock.timers.tick(60_000); + await act(async () => { + await new Promise((resolve) => setImmediate(resolve)); + }); + } + assert.ok(isSettled(result.current), "query never settled in error"); + unmount(); + } finally { + cleanup(); + queryClient.clear(); + mock.timers.reset(); + } +} + +for (const { name, message, deadline } of CASES) { + test(`createBuzzQueryClient default retry: ${name}`, async () => { + let calls = 0; + await fetchCountUntilError( + () => + useQuery({ + queryKey: ["deadline-default", name], + queryFn: async () => { + calls += 1; + throw new Error(message); + }, + }), + (r) => r.isError, + ); + assert.equal(calls, deadline ? 1 : 2); + }); + + test(`useThreadRepliesForRoots (3 roots) retry: ${name}`, async () => { + const calls = new Map(); + globalThis.__tauriGetThreadReplies = async (rootId) => { + calls.set(rootId, (calls.get(rootId) ?? 0) + 1); + throw new Error(message); + }; + const channel = { id: `chan-roots-${name}`, channelType: "group" }; + const roots = ["root-a", "root-b", "root-c"]; + await fetchCountUntilError( + () => useThreadRepliesForRoots(channel, roots), + (r) => r.isError && !r.isPending, + ); + assert.deepEqual( + Object.fromEntries(calls), + Object.fromEntries(roots.map((root) => [root, deadline ? 1 : 2])), + ); + }); + + test(`useThreadReplies single-root retry: ${name}`, async () => { + let calls = 0; + globalThis.__tauriGetThreadReplies = async () => { + calls += 1; + throw new Error(message); + }; + const channel = { id: `chan-single-${name}`, channelType: "group" }; + await fetchCountUntilError( + () => useThreadReplies(channel, "root-single"), + (r) => r.isError, + ); + // useThreadReplies keeps its own `< 3` retries for ordinary failures. + assert.equal(calls, deadline ? 1 : 4); + }); +} diff --git a/desktop/src/features/messages/useThreadReplies.ts b/desktop/src/features/messages/useThreadReplies.ts index 9ad86e46a8..e23eb2a189 100644 --- a/desktop/src/features/messages/useThreadReplies.ts +++ b/desktop/src/features/messages/useThreadReplies.ts @@ -11,6 +11,7 @@ import { sortMessages, } from "@/features/messages/lib/messageQueryKeys"; import { getThreadReplies } from "@/shared/api/tauri"; +import { isQueryDeadlineError } from "@/shared/lib/relayError"; import type { Channel, RelayEvent, @@ -163,7 +164,10 @@ export function useThreadReplies( // throws on attempts 1 and 2; attempt 3 records exhaustion first so // loadThreadReplies returns data directly — the terminal attempt always // resolves to success. - retry: 3, + // A read that hit the relay's statement deadline is not retried: + // each retry would re-run the same slow server-side query. + retry: (failureCount, error) => + failureCount < 3 && !isQueryDeadlineError(error), retryDelay: (attempt) => Math.min(1_000 * 2 ** attempt, 30_000), }); diff --git a/desktop/src/features/profile/profileBatchResilience.test.mjs b/desktop/src/features/profile/profileBatchResilience.test.mjs index 53f8c1ff11..7939581600 100644 --- a/desktop/src/features/profile/profileBatchResilience.test.mjs +++ b/desktop/src/features/profile/profileBatchResilience.test.mjs @@ -43,8 +43,13 @@ test("useUsersBatchQuery: global queryClient defaults are unchanged", async () = new URL("../../shared/api/queryClient.ts", import.meta.url), "utf8", ); - // Global retry must remain 1 — other queries chose that semantics. - assert.match(source, /retry:\s*1/, "global retry default must remain 1"); + // Global retry budget must remain 1 — other queries chose that semantics. + // Query-deadline failures are exempt so a slow server query is not re-run. + assert.match( + source, + /failureCount < 1 && !isQueryDeadlineError\(error\)/, + "global retry default must remain 1, skipping query deadlines", + ); // Global refetchOnWindowFocus must remain false. assert.match( source, diff --git a/desktop/src/shared/api/queryClient.ts b/desktop/src/shared/api/queryClient.ts index 6302f4e064..5590f1d967 100644 --- a/desktop/src/shared/api/queryClient.ts +++ b/desktop/src/shared/api/queryClient.ts @@ -1,5 +1,6 @@ import { focusManager, QueryClient } from "@tanstack/react-query"; +import { isQueryDeadlineError } from "@/shared/lib/relayError"; import { isAppFocused, subscribeAppFocus, @@ -25,7 +26,10 @@ export function createBuzzQueryClient() { return new QueryClient({ defaultOptions: { queries: { - retry: 1, + // One retry, except after the relay's `query timed out` answer: that + // would re-run the same slow server-side query. + retry: (failureCount, error) => + failureCount < 1 && !isQueryDeadlineError(error), refetchOnWindowFocus: false, networkMode: "always", gcTime: 5 * 60 * 1_000, diff --git a/desktop/src/shared/api/relayClosedPolicy.test.mjs b/desktop/src/shared/api/relayClosedPolicy.test.mjs index 2e5cad4b7c..49f8189cca 100644 --- a/desktop/src/shared/api/relayClosedPolicy.test.mjs +++ b/desktop/src/shared/api/relayClosedPolicy.test.mjs @@ -26,6 +26,7 @@ test("classifyRelayClosed: terminal messages return terminal", () => { "unsupported: filter", "error: mixed search and non-search filters not supported", "error: too many subscriptions", + "error: query timed out", ]) { assert.equal(classifyRelayClosed(message), "terminal", message); } diff --git a/desktop/src/shared/api/relayClosedPolicy.ts b/desktop/src/shared/api/relayClosedPolicy.ts index 9e747a2df1..a5ff5372ef 100644 --- a/desktop/src/shared/api/relayClosedPolicy.ts +++ b/desktop/src/shared/api/relayClosedPolicy.ts @@ -32,7 +32,10 @@ export function classifyRelayClosed(message: string): RelayClosedClass { normalized.startsWith("duplicate:") || normalized.startsWith("unsupported:") || normalized.startsWith("error: mixed search") || - normalized.startsWith("error: too many subscriptions") + normalized.startsWith("error: too many subscriptions") || + // Server statement deadline: re-sending the same REQ re-runs the same + // slow query, so a live retry loop would hammer the database. + normalized.startsWith("error: query timed out") ) { return "terminal"; } diff --git a/desktop/src/shared/api/relayClosedRecovery.test.mjs b/desktop/src/shared/api/relayClosedRecovery.test.mjs index de75aad3ba..c09b90e4c5 100644 --- a/desktop/src/shared/api/relayClosedRecovery.test.mjs +++ b/desktop/src/shared/api/relayClosedRecovery.test.mjs @@ -670,6 +670,35 @@ test("terminal CLOSED deletes subscription and does not retry", () => { assert.equal(firedAt.length, 0, "terminal CLOSED must not retry"); }); +test("live CLOSED query deadline retries with backoff instead of removing", () => { + resetAll(0); + const firedAt = []; + const subscriptions = new Map([ + [ + "live-1", + { + mode: "live", + filter: { kinds: [9], "#h": ["ch-1"], limit: 1000 }, + onEvent: () => {}, + resolveReady: () => {}, + }, + ], + ]); + handleRelayClosed({ + subscriptions, + subId: "live-1", + message: "error: query timed out", + sendReq: () => { + firedAt.push(fakeNow); + return Promise.resolve(); + }, + }); + assert.equal(subscriptions.has("live-1"), true, "live sub must survive"); + assert.equal(firedAt.length, 0, "retry waits for backoff"); + tickTo(10_000); + assert.equal(firedAt.length, 1, "live sub must be re-sent after backoff"); +}); + // ── Test: rejecting closeSubscription does not produce an unhandled rejection ─ // // Load-bearing for the `.catch(() => {})` guard on the op-timeout CLOSE send. diff --git a/desktop/src/shared/api/relayClosedRecovery.ts b/desktop/src/shared/api/relayClosedRecovery.ts index cf04d83126..348d55e299 100644 --- a/desktop/src/shared/api/relayClosedRecovery.ts +++ b/desktop/src/shared/api/relayClosedRecovery.ts @@ -1,4 +1,5 @@ import { classifyRelayClosed } from "@/shared/api/relayClosedPolicy"; +import { isQueryDeadlineError } from "@/shared/lib/relayError"; import { activateRateLimit, parseRateLimitHint, @@ -132,7 +133,13 @@ function recoverLiveSubscriptionFromClosed({ subscription.resolveReady?.("closed"); subscription.resolveReady = undefined; - const closedClass = classifyRelayClosed(message); + // A deadline on a live sub is treated as retryable: resubscribe with the + // same filter under the 1-30s backoff, which can keep retrying at the cap. + // `since` is deliberately not advanced to now; that could skip events + // missed while the sub was closed. + const closedClass = isQueryDeadlineError(message) + ? "retryable" + : classifyRelayClosed(message); if (closedClass === "terminal") { // Auth/access/filter failure — permanently remove the subscription so it diff --git a/desktop/src/shared/lib/relayError.test.mjs b/desktop/src/shared/lib/relayError.test.mjs index 7ae121f2b5..07b018b9b1 100644 --- a/desktop/src/shared/lib/relayError.test.mjs +++ b/desktop/src/shared/lib/relayError.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { isRelayUnreachableError } from "./relayError.ts"; +import { isQueryDeadlineError, isRelayUnreachableError } from "./relayError.ts"; test("isRelayUnreachableError: Error with prefix returns true", () => { assert.equal( @@ -55,3 +55,39 @@ test("isRelayUnreachableError: plain object returns false", () => { false, ); }); + +test("isQueryDeadlineError: relay statement deadline, HTTP and WS forms", () => { + assert.equal( + isQueryDeadlineError( + new Error("relay returned 503 Service Unavailable: query timed out"), + ), + true, + ); + assert.equal(isQueryDeadlineError("error: query timed out"), true); +}); + +test("isQueryDeadlineError: client-side timeouts stay retryable", () => { + for (const message of [ + "relay unreachable: request timed out", + "relay request timed out", + "relay request timed out or cancelled", + ]) { + assert.equal(isQueryDeadlineError(new Error(message)), false, message); + } +}); + +test("isQueryDeadlineError: other relay errors stay retryable", () => { + assert.equal( + isQueryDeadlineError( + new Error( + "relay returned 500 Internal Server Error: internal server error", + ), + ), + false, + ); + assert.equal( + isQueryDeadlineError(new Error("relay unreachable: 403")), + false, + ); + assert.equal(isQueryDeadlineError(undefined), false); +}); diff --git a/desktop/src/shared/lib/relayError.ts b/desktop/src/shared/lib/relayError.ts index ca16aa41e2..285a8651fd 100644 --- a/desktop/src/shared/lib/relayError.ts +++ b/desktop/src/shared/lib/relayError.ts @@ -32,3 +32,14 @@ export function isRelayUnreachableError(error: unknown): boolean { } return false; } + +/** + * Returns true only for the relay's own statement-deadline answer: HTTP 503 + * `query timed out` or WS CLOSED `error: query timed out`. Retrying re-runs + * the same expensive query, so callers should not. Client-side timeouts + * (`request timed out`) are network stalls and stay retryable. + */ +export function isQueryDeadlineError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return message.includes("query timed out"); +} diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 468dbb0486..d518bd1878 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -25,6 +25,7 @@ import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/flapping_bee.dart'; import '../../shared/widgets/keyboard_dismiss_on_drag.dart'; +import '../../shared/widgets/load_error_view.dart'; import '../../shared/widgets/ios_glass_navigation_button.dart'; import '../../shared/widgets/masked_avatar_badge.dart'; import '../../shared/widgets/message_author_meta.dart'; @@ -715,12 +716,10 @@ class ChannelDetailPage extends HookConsumerWidget { titleContentHeight: appBarTitleContentHeight, ), ), - child: Center( - child: Text( - 'Failed to load messages', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.error, - ), + child: LoadErrorView( + message: 'Failed to load messages', + onRetry: () => ref.invalidate( + channelMessagesProvider(channel.id), ), ), ), diff --git a/mobile/lib/features/channels/channel_messages_provider.dart b/mobile/lib/features/channels/channel_messages_provider.dart index 221aef367a..a87fdf7c7f 100644 --- a/mobile/lib/features/channels/channel_messages_provider.dart +++ b/mobile/lib/features/channels/channel_messages_provider.dart @@ -247,6 +247,8 @@ class ChannelMessagesNotifier extends Notifier>> { } catch (error) { _initialWindowQueryInFlight = false; _liveSummaryRootsDuringInitialWindowQuery.clear(); + // Legacy history would re-run the timed-out work another way. + if (isRelayDeadlineError(error)) rethrow; debugPrint( '[ChannelMessagesNotifier] channel window unavailable for $channelId, falling back to WS history: $error', ); diff --git a/mobile/lib/features/channels/channel_messages_provider/thread_summaries.dart b/mobile/lib/features/channels/channel_messages_provider/thread_summaries.dart index 7fa36c2b14..7d22de342b 100644 --- a/mobile/lib/features/channels/channel_messages_provider/thread_summaries.dart +++ b/mobile/lib/features/channels/channel_messages_provider/thread_summaries.dart @@ -332,7 +332,8 @@ extension _ThreadSummaryState on ChannelMessagesNotifier { } } catch (error) { if (!current()) return; - if (attempt < 2) { + // Re-sending a timed-out recount re-runs the same slow scan. + if (attempt < 2 && !isRelayDeadlineError(error)) { // Retain this active queue slot during backoff, so retries share the // same concurrency budget and stop when newer work supersedes them. await Future.delayed(Duration(milliseconds: 500 << attempt)); diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index c5a77ba930..982e39fa2a 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -338,8 +338,18 @@ class ChannelsNotifier extends AsyncNotifier> { _fetchLastMessageEvents(session, activeChannels), ); final lastMessageMap = {}; + // An unavailable batch keeps the timestamps already known rather than + // installing channels whose latest message looks absent. + if (events == null) { + for (final channel in state.value ?? const []) { + final at = channel.lastMessageAt; + if (at != null) { + lastMessageMap[channel.id] = at.millisecondsSinceEpoch ~/ 1000; + } + } + } final mutedChannelIds = _mutedChannelIds(); - for (final event in events) { + for (final event in events ?? const []) { final channelId = event.channelId; if (channelId == null) continue; final channel = channelById[channelId]; @@ -423,7 +433,7 @@ class ChannelsNotifier extends AsyncNotifier> { /// bridge request. The relay preserves NIP-01 per-filter limits while /// executing the filters with bounded concurrency, avoiding an unbounded /// burst of websocket REQs on communities with many channels. - Future> _fetchLastMessageEvents( + Future?> _fetchLastMessageEvents( RelaySessionNotifier session, List channels, ) async { @@ -447,7 +457,8 @@ class ChannelsNotifier extends AsyncNotifier> { ); } - Future> _fetchChannelHistoryBatch( + /// Returns null when the batch is unavailable: it hit a relay deadline. + Future?> _fetchChannelHistoryBatch( RelaySessionNotifier session, List filters, { required String operation, @@ -457,6 +468,11 @@ class ChannelsNotifier extends AsyncNotifier> { try { return await session.queryRelay(filters); } catch (error) { + // Per-filter fallback would re-run the timed-out work another way. + if (isRelayDeadlineError(error)) { + debugPrint('[ChannelsNotifier] batched $operation hit the deadline'); + return null; + } debugPrint( '[ChannelsNotifier] batched $operation failed; ' 'using bounded websocket fallback: $error', @@ -465,13 +481,19 @@ class ChannelsNotifier extends AsyncNotifier> { const fallbackConcurrency = 4; final events = []; + var deadline = false; for (var start = 0; start < filters.length; start += fallbackConcurrency) { + // Stop sending once a filter hit the deadline; partial events are not + // a result. + if (deadline) return null; final end = min(start + fallbackConcurrency, filters.length); final results = await Future.wait( filters.sublist(start, end).map((filter) async { try { return await session.fetchHistory(filter); - } catch (_) { + } catch (error) { + // A timed-out filter makes the batch unavailable, not empty. + if (isRelayDeadlineError(error)) deadline = true; return const []; } }), @@ -480,6 +502,7 @@ class ChannelsNotifier extends AsyncNotifier> { events.addAll(result); } } + if (deadline) return null; return events; } @@ -592,6 +615,8 @@ class ChannelsNotifier extends AsyncNotifier> { filters, operation: 'unread catch-up', ); + // Unavailable: keep the unread state already observed. + if (events == null) return; // The relay round-trip above is the window Jed's probes park in: a newer // refresh, a community switch or an identity switch here means every // write below belongs to a channel list the user has left. @@ -605,7 +630,7 @@ class ChannelsNotifier extends AsyncNotifier> { } } - var recorded = false; + final recordedAtByChannel = {}; for (final event in events) { final channelId = event.channelId; if (channelId == null) continue; @@ -626,15 +651,25 @@ class ChannelsNotifier extends AsyncNotifier> { continue; } _recordUnreadEvent(channel, event, myPk); - recorded = true; + recordedAtByChannel[channelId] = max( + recordedAtByChannel[channelId] ?? 0, + event.createdAt, + ); } // Republish only when this catch-up actually changed unread state. A // batch that recorded nothing has nothing to show, and a failed or // superseded batch must not repaint another refresh's list: the retired // check above already returned in that case, and no await separates it // from here, so a second check would be dead code. - if (recorded) { - state = state.whenData((channels) => List.of(channels)); + // Recorded events also advance `lastMessageAt`, as live events do: a + // deadline-unavailable latest-message batch leaves it unset. + if (recordedAtByChannel.isNotEmpty) { + state = state.whenData( + (channels) => [ + for (final channel in channels) + _advanceLastMessageAt(channel, recordedAtByChannel[channel.id]), + ], + ); } } catch (error) { if (!ref.mounted) return; @@ -642,6 +677,17 @@ class ChannelsNotifier extends AsyncNotifier> { } } + static Channel _advanceLastMessageAt(Channel channel, int? createdAt) { + if (createdAt == null) return channel; + final at = DateTime.fromMillisecondsSinceEpoch( + createdAt * 1000, + isUtc: true, + ); + final current = channel.lastMessageAt; + if (current != null && !at.isAfter(current)) return channel; + return channel.copyWith(lastMessageAt: at); + } + void _handleLiveEvent(NostrEvent event) { final channelId = event.channelId; if (channelId == null) return; diff --git a/mobile/lib/features/channels/thread_detail_helpers.dart b/mobile/lib/features/channels/thread_detail_helpers.dart index 7ff39fac53..fdb92f6020 100644 --- a/mobile/lib/features/channels/thread_detail_helpers.dart +++ b/mobile/lib/features/channels/thread_detail_helpers.dart @@ -173,3 +173,23 @@ class _ThreadTypingIndicator extends StatelessWidget { ); } } + +/// Display evidence while the authoritative thread query has no value (still +/// loading, or failed on first load): the route snapshot plus live, cached +/// and optimistic replies. The page keeps the query's loading/error status, +/// so this list is never presented as complete, and nothing is re-queried. +List _provisionalThreadMessages( + List routeSnapshot, + List observed, + List liveChannelEvents, +) { + final byId = { + for (final message in routeSnapshot) + if (!_isDeletedBy(liveChannelEvents, message.id)) message.id: message, + for (final message in observed) message.id: message, + }; + return byId.values.toList()..sort((a, b) { + final order = a.createdAt.compareTo(b.createdAt); + return order != 0 ? order : a.id.compareTo(b.id); + }); +} diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 0bb4cedb34..56d7015b0f 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../shared/mentions/agent_identity_provider.dart'; @@ -148,8 +149,13 @@ class ThreadDetailPage extends HookConsumerWidget { liveChannelEvents, threadHead.id, ); - final allMsgs = fetchedReplies == null - ? allMessages + final allMsgs = fetchedReplies == null || !relayRepliesAvailable + ? _provisionalThreadMessages( + allMessages, + fetchedReplies ?? + formatTimeline(liveChannelEvents, currentPubkey: currentPubkey), + liveChannelEvents, + ) : [ // Only fall back to the pushed-route snapshot when neither source // carries the head, and no live deletion has suppressed it. That @@ -894,6 +900,8 @@ class ThreadDetailPage extends HookConsumerWidget { bottomInset: timelineBottomInset, replies: replies, relayReplyState: relayReplyState, + onRetryReplies: () => + ref.invalidate(threadRepliesProvider(repliesArgs)), localSendAnimations: localSendAnimations, trackActiveScrollPosition: trackActiveScrollPosition, headIsDeleted: liveDeletionHidesHead, diff --git a/mobile/lib/features/channels/thread_detail_page/message_list.dart b/mobile/lib/features/channels/thread_detail_page/message_list.dart index a33aa9a4ee..28160ee88b 100644 --- a/mobile/lib/features/channels/thread_detail_page/message_list.dart +++ b/mobile/lib/features/channels/thread_detail_page/message_list.dart @@ -10,6 +10,7 @@ class _ThreadMessageList extends StatelessWidget { final double bottomInset; final List replies; final AsyncValue> relayReplyState; + final VoidCallback onRetryReplies; final Map localSendAnimations; final Widget Function(Widget child) trackActiveScrollPosition; final bool headIsDeleted; @@ -36,6 +37,7 @@ class _ThreadMessageList extends StatelessWidget { required this.bottomInset, required this.replies, required this.relayReplyState, + required this.onRetryReplies, required this.localSendAnimations, required this.trackActiveScrollPosition, required this.headIsDeleted, @@ -164,6 +166,17 @@ class _ThreadMessageList extends StatelessWidget { ), ), ), + if (relayReplyState.hasError && + !relayReplyState.isLoading) + IconButton( + key: const ValueKey('thread-replies-retry'), + onPressed: onRetryReplies, + tooltip: 'Retry', + icon: const Icon( + LucideIcons.refreshCcw, + size: 16, + ), + ), const SizedBox(width: Grid.xxs), Expanded( child: Divider( diff --git a/mobile/lib/features/forum/forum_posts_view.dart b/mobile/lib/features/forum/forum_posts_view.dart index 13880427cf..352c1bc73f 100644 --- a/mobile/lib/features/forum/forum_posts_view.dart +++ b/mobile/lib/features/forum/forum_posts_view.dart @@ -5,9 +5,11 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/relay/relay_closed_policy.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/load_error_view.dart'; import '../../shared/widgets/bee_refresh_indicator.dart'; import '../channels/channel.dart'; import '../channels/compose_bar.dart'; @@ -39,10 +41,22 @@ class ForumPostsView extends HookConsumerWidget { final providerContainer = ProviderScope.containerOf(context, listen: false); final forumDelivery = ForumEventDelivery.capture(providerContainer); - // Periodic refresh (every 15s, matching desktop). + // Periodic refresh (every 15s, matching desktop). A settled deadline + // pauses polling; reopening the forum is the explicit retry. useEffect(() { + final provider = forumPostsProvider(channel.id); + // Only a deadline already settled when this surface mounts is a + // reopen; a first load that fails after mounting is not retried. + // Hooks run effects during build, so defer the invalidation past it. + if (isSettledRelayDeadline(ref.read(provider))) { + Future.microtask(() { + if (context.mounted) ref.invalidate(provider); + }); + } final timer = Stream.periodic(const Duration(seconds: 15)).listen((_) { - ref.invalidate(forumPostsProvider(channel.id)); + if (!isSettledRelayDeadline(ref.read(provider))) { + ref.invalidate(provider); + } }); return timer.cancel; }, [channel.id]); @@ -65,6 +79,8 @@ class ForumPostsView extends HookConsumerWidget { ) : null, body: postsAsync.when( + // A retry from an error shows loading, not the stale error and its Retry. + skipLoadingOnRefresh: !postsAsync.hasError, loading: () => Padding( padding: EdgeInsets.only(top: frostedAppBarHeight(context)), child: const Center( @@ -76,13 +92,9 @@ class ForumPostsView extends HookConsumerWidget { ), error: (e, _) => Padding( padding: EdgeInsets.only(top: frostedAppBarHeight(context)), - child: Center( - child: Text( - 'Failed to load posts', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.error, - ), - ), + child: LoadErrorView( + message: 'Failed to load posts', + onRetry: () => ref.invalidate(forumPostsProvider(channel.id)), ), ), data: (response) { diff --git a/mobile/lib/features/forum/forum_thread_page.dart b/mobile/lib/features/forum/forum_thread_page.dart index 298630b42c..8b802bac4a 100644 --- a/mobile/lib/features/forum/forum_thread_page.dart +++ b/mobile/lib/features/forum/forum_thread_page.dart @@ -7,11 +7,13 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/mentions/agent_identity_provider.dart'; +import '../../shared/relay/relay_closed_policy.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; +import '../../shared/widgets/load_error_view.dart'; import '../../shared/widgets/modal_presentation.dart'; import '../channels/compose_bar.dart'; import '../channels/message_content.dart'; @@ -45,12 +47,25 @@ class ForumThreadPage extends HookConsumerWidget { forumThreadProvider((channelId: channelId, eventId: postEventId)), ); - // Periodic refresh (every 10s, matching desktop). + // Periodic refresh (every 10s, matching desktop). A settled deadline + // pauses polling; reopening the thread is the explicit retry. useEffect(() { + final provider = forumThreadProvider(( + channelId: channelId, + eventId: postEventId, + )); + // Only a deadline already settled when this surface mounts is a + // reopen; a first load that fails after mounting is not retried. + // Hooks run effects during build, so defer the invalidation past it. + if (isSettledRelayDeadline(ref.read(provider))) { + Future.microtask(() { + if (context.mounted) ref.invalidate(provider); + }); + } final timer = Stream.periodic(const Duration(seconds: 10)).listen((_) { - ref.invalidate( - forumThreadProvider((channelId: channelId, eventId: postEventId)), - ); + if (!isSettledRelayDeadline(ref.read(provider))) { + ref.invalidate(provider); + } }); return timer.cancel; }, [channelId, postEventId]); @@ -79,6 +94,8 @@ class ForumThreadPage extends HookConsumerWidget { ], ), body: threadAsync.when( + // A retry from an error shows loading, not the stale error and its Retry. + skipLoadingOnRefresh: !threadAsync.hasError, loading: () => Padding( padding: EdgeInsets.only(top: frostedAppBarHeight(context)), child: const Center( @@ -90,12 +107,10 @@ class ForumThreadPage extends HookConsumerWidget { ), error: (e, _) => Padding( padding: EdgeInsets.only(top: frostedAppBarHeight(context)), - child: Center( - child: Text( - 'Failed to load thread', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.error, - ), + child: LoadErrorView( + message: 'Failed to load thread', + onRetry: () => ref.invalidate( + forumThreadProvider((channelId: channelId, eventId: postEventId)), ), ), ), diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 5b40be40d3..6c1aa734df 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -6,6 +6,7 @@ import 'app.dart'; import 'features/age_gate/age_signal_push_bootstrap.dart'; import 'features/invites/invite_join_provider.dart'; import 'shared/push/push_bridge.dart'; +import 'shared/relay/relay_closed_policy.dart'; import 'shared/theme/theme_provider.dart'; void main() => runBuzzApp(const App()); @@ -18,16 +19,26 @@ Future runBuzzApp(Widget app) async { // Pre-load preferences so the first frame uses the saved theme/accent. final prefs = await SharedPreferences.getInstance(); - runApp( - ProviderScope( - overrides: [ - savedPrefsProvider.overrideWithValue(prefs), - inviteJoinRecoveryProvider.overrideWith( - (ref) => - (scope) => buildMobileInviteJoinRecovery(ref, scope), - ), - ], - child: AgeSignalPushBootstrap(child: app), - ), - ); + runApp(buildRootProviderScope(prefs: prefs, child: app)); } + +/// The app's root scope, shared with tests so its production wiring is +/// asserted rather than re-declared. +ProviderScope buildRootProviderScope({ + required SharedPreferences prefs, + required Widget child, +}) => ProviderScope( + // A relay statement deadline cannot resolve on retry — the same expensive + // query would re-run and hit the same limit. Return null (no retry) for + // deadline errors and fall through to the default exponential backoff for + // everything else. + retry: relayProviderRetry, + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + inviteJoinRecoveryProvider.overrideWith( + (ref) => + (scope) => buildMobileInviteJoinRecovery(ref, scope), + ), + ], + child: AgeSignalPushBootstrap(child: child), +); diff --git a/mobile/lib/shared/relay/relay_closed_policy.dart b/mobile/lib/shared/relay/relay_closed_policy.dart index d39084b189..4013470c7c 100644 --- a/mobile/lib/shared/relay/relay_closed_policy.dart +++ b/mobile/lib/shared/relay/relay_closed_policy.dart @@ -1,3 +1,50 @@ +import 'dart:convert'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'relay_client.dart'; + +// Shared constant so [isRelayDeadlineError] and [classifyRelayClosed] cannot +// drift apart. +const _queryTimedOutPrefix = 'error: query timed out'; + +/// Returns true when [error] is a relay server-side statement deadline that +/// cannot be resolved by retrying the same query. +/// +/// Two surfaces carry the same deadline signal: +/// - HTTP `/query` or `/count`: [RelayException] with status 503 and a JSON +/// body containing `"error": "query timed out"`. +/// - WebSocket CLOSED: a plain [Exception] whose message is +/// `"error: query timed out"` (the relay's stable CLOSED reason string). +/// +/// Both surfaces are checked so the caller (e.g. a Riverpod retry hook) does +/// not need to distinguish which transport produced the error. +bool isRelayDeadlineError(Object error) { + if (error is RelayException && error.statusCode == 503) { + try { + final decoded = jsonDecode(error.body); + if (decoded is Map) { + final message = decoded['error']; + if (message is String && + message.trim().toLowerCase() == 'query timed out') { + return true; + } + } + } on FormatException { + // Non-JSON 503 — not a deadline. + } + return false; + } + // WebSocket history CLOSED: Exception("error: query timed out") + // Strip the "Exception: " prefix Flutter adds to Exception.toString(), then + // check via classifyRelayClosed so this path shares the same source of truth. + var message = error.toString().trim().toLowerCase(); + const prefix = 'exception: '; + if (message.startsWith(prefix)) message = message.substring(prefix.length); + return classifyRelayClosed(message) == RelayClosedClass.terminal && + message.startsWith(_queryTimedOutPrefix); +} + /// Recovery policy for a relay `CLOSED` subscription message. enum RelayClosedClass { /// A transient failure that may recover when the same REQ is retried. @@ -24,7 +71,10 @@ RelayClosedClass classifyRelayClosed(String message) { normalized.startsWith('duplicate:') || normalized.startsWith('unsupported:') || normalized.startsWith('error: mixed search') || - normalized.startsWith('error: too many subscriptions')) { + normalized.startsWith('error: too many subscriptions') || + // Server statement deadline: re-sending the same REQ re-runs the same + // slow query. + normalized.startsWith(_queryTimedOutPrefix)) { return RelayClosedClass.terminal; } return RelayClosedClass.retryable; @@ -37,3 +87,25 @@ int? parseRateLimitRetrySeconds(String message) { final match = _rateLimitRetryPattern.firstMatch(message); return match == null ? null : int.tryParse(match.group(1)!); } + +/// Whether [value] has settled on a relay deadline. +/// +/// The forum periodic poll timers pause while a query is in this state; +/// reopening the view is the explicit retry. Other refresh paths (reconnect, +/// resume, live events, unread backstop) still re-send, each bounded by the +/// relay's 20s statement deadline. +bool isSettledRelayDeadline(AsyncValue value) => + value.hasError && !value.isLoading && isRelayDeadlineError(value.error!); + +/// Production Riverpod retry policy for relay providers. +/// +/// A relay statement deadline cannot resolve on retry — the same expensive +/// query would re-run and hit the same limit. Returns `null` (no retry) for +/// deadline errors and falls through to [ProviderContainer.defaultRetry] for +/// everything else. +/// +/// Pass this to [ProviderScope.retry] at the root of the widget tree. +Duration? relayProviderRetry(int retryCount, Object error) { + if (isRelayDeadlineError(error)) return null; + return ProviderContainer.defaultRetry(retryCount, error); +} diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 51fb8bf51d..ece4b9266b 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -730,7 +730,12 @@ class RelaySessionNotifier extends Notifier { final liveSub = _liveSubscriptions[subId]; if (liveSub == null) return; final readyCompleter = liveSub.readyCompleter; - if (closedClass == RelayClosedClass.terminal) { + // A deadline is terminal for one-shot history, but a live subscription + // retries it: same filter under backoff, which can keep retrying at the + // cap. `since` is not advanced, so events missed while closed aren't + // skipped. + if (closedClass == RelayClosedClass.terminal && + !isRelayDeadlineError(message)) { if (readyCompleter != null && !readyCompleter.isCompleted) { readyCompleter.completeError(Exception(message)); } diff --git a/mobile/lib/shared/widgets/load_error_view.dart b/mobile/lib/shared/widgets/load_error_view.dart new file mode 100644 index 0000000000..50952e3cb8 --- /dev/null +++ b/mobile/lib/shared/widgets/load_error_view.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../theme/theme.dart'; + +/// Settled load failure with an explicit Retry. After a relay deadline, +/// Riverpod does not auto-retry, so this is the user's way to re-send. +class LoadErrorView extends StatelessWidget { + final String message; + final VoidCallback onRetry; + + const LoadErrorView({ + super.key, + required this.message, + required this.onRetry, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.triangleAlert, + size: Grid.lg, + color: context.colors.error, + ), + const SizedBox(height: Grid.xxs), + Text( + message, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Grid.xs), + FilledButton.icon( + key: const ValueKey('load-error-retry'), + onPressed: onRetry, + icon: const Icon(LucideIcons.refreshCcw, size: 16), + label: const Text('Retry'), + ), + ], + ), + ); + } +} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 20251f2195..4feaa96732 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -10187,6 +10187,62 @@ void main() { }); group('Error and loading states', () { + Widget errorScope(ChannelMessagesNotifier notifier) => ProviderScope( + overrides: [ + channelMessagesProvider(_channelId).overrideWith(() => notifier), + channelTypingProvider( + _channelId, + ).overrideWith(() => _FakeTypingNotifier([])), + userCacheProvider.overrideWith(() => _FakeUserCacheNotifier({})), + channelsProvider.overrideWith( + () => _FakeChannelsNotifier([_testChannel]), + ), + relayClientProvider.overrideWithValue( + RelayClient(baseUrl: 'http://localhost:3000'), + ), + savedPrefsProvider.overrideWithValue(_testPrefs), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: ChannelDetailPage(channel: _testChannel), + ), + ); + final retry = find.byKey(const ValueKey('load-error-retry')); + + for (final (name, error) in [ + ( + 'deadline', + RelayException(503, '{"error":"query timed out"}') as Object, + ), + ('ordinary error', Exception('bridge down') as Object), + ]) { + testWidgets('Retry after a $name loads once', (tester) async { + final notifier = _RetryCountingMessagesNotifier( + () => AsyncError(error, StackTrace.current), + ); + await tester.pumpWidget(errorScope(notifier)); + await tester.pumpAndSettle(); + expect(find.text('Failed to load messages'), findsOneWidget); + expect(notifier.loads, 1); + final before = notifier.loads; + notifier.nextResult = () => const AsyncData([]); + await tester.tap(retry); + await tester.pumpAndSettle(); + expect(notifier.loads, before + 1); + expect(retry, findsNothing); + }); + } + + testWidgets('no Retry while messages load', (tester) async { + final notifier = _RetryCountingMessagesNotifier( + () => const AsyncLoading(), + ); + await tester.pumpWidget(errorScope(notifier)); + await tester.pump(); + expect(retry, findsNothing); + expect(find.text('Failed to load messages'), findsNothing); + }); + testWidgets('shows error message on failure', (tester) async { await tester.pumpWidget( ProviderScope( @@ -10891,6 +10947,225 @@ void main() { expect(highlightedDecoration.color!.a, lessThan(0.12)); }); + group('after a first-load deadline', () { + final root = _textMsg( + id: 'root', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final mid = _textMsg( + id: 'mid', + pubkey: 'bob', + content: 'Nested parent', + createdAt: 1100, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + + Future<(_FakeMessagesNotifier, int Function())> openDeadlined( + WidgetTester tester, + NostrEvent head, + ) async { + var attempts = 0; + final messages = _FakeMessagesNotifier([root, mid]); + final timeline = formatTimeline([root, mid]); + await tester.pumpWidget( + _buildTestable( + messages: [root, mid], + messagesNotifier: messages, + disableRetries: true, + threadReplyLoaders: { + 'root': () { + attempts++; + return Future.error( + RelayException(503, '{"error":"query timed out"}'), + ); + }, + }, + home: ThreadDetailPage( + threadHead: timeline.firstWhere((m) => m.id == head.id), + allMessages: timeline, + channelId: _testChannel.id, + currentPubkey: 'me', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + expect(attempts, 1); + return (messages, () => attempts); + } + + NostrEvent liveReply(String id, List> tags) => _textMsg( + id: id, + pubkey: 'carol', + content: 'Live $id', + createdAt: 1200, + extraTags: tags, + ); + + testWidgets('shows an incoming direct reply', (tester) async { + final (messages, attempts) = await openDeadlined(tester, root); + messages.setMessages([ + root, + mid, + liveReply('direct', const [ + ['e', 'root', '', 'reply'], + ]), + ]); + await tester.pumpAndSettle(); + expect(find.text('Live direct'), findsOneWidget); + expect(find.textContaining('Couldn’t refresh'), findsOneWidget); + expect(attempts(), 1); + }); + + for (final provisional in [false, true]) { + testWidgets( + 'Retry ${provisional ? 'beside provisional replies' : 'in the empty state'} ' + 'loads once', + (tester) async { + // The empty variant has no reply at all, cached or provisional. + final seed = provisional ? [root, mid] : [root]; + final timeline = formatTimeline(seed); + var fail = true; + var loads = 0; + final messages = _FakeMessagesNotifier(seed); + await tester.pumpWidget( + _buildTestable( + messages: seed, + messagesNotifier: messages, + disableRetries: true, + threadReplyLoaders: { + 'root': () { + loads++; + if (!fail) return Future.value(const []); + return Future.error( + RelayException(503, '{"error":"query timed out"}'), + ); + }, + }, + home: ThreadDetailPage( + threadHead: timeline.firstWhere((m) => m.id == 'root'), + allMessages: timeline, + channelId: _testChannel.id, + currentPubkey: 'me', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + final retry = find.byKey(const ValueKey('thread-replies-retry')); + if (provisional) { + messages.setMessages([ + root, + mid, + liveReply('direct', const [ + ['e', 'root', '', 'reply'], + ]), + ]); + await tester.pumpAndSettle(); + expect(find.text('Live direct'), findsOneWidget); + } else { + expect(find.text('Couldn’t load replies'), findsOneWidget); + } + expect(retry, findsOneWidget); + final target = tester.getSize(retry); + expect( + target.width, + greaterThanOrEqualTo(kMinInteractiveDimension), + ); + expect( + target.height, + greaterThanOrEqualTo(kMinInteractiveDimension), + ); + final before = loads; + fail = false; + await tester.tap(retry); + await tester.pumpAndSettle(); + expect(loads, before + 1); + expect(retry, findsNothing); + }, + ); + } + + testWidgets('no replies Retry while the scan loads', (tester) async { + final timeline = formatTimeline([root, mid]); + await tester.pumpWidget( + _buildTestable( + messages: [root, mid], + threadReplyLoaders: { + 'root': () => Completer>().future, + }, + home: ThreadDetailPage( + threadHead: timeline.firstWhere((m) => m.id == 'root'), + allMessages: timeline, + channelId: _testChannel.id, + currentPubkey: 'me', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pump(); + await tester.pump(); + expect(find.textContaining('Couldn’t'), findsNothing); + expect( + find.byKey(const ValueKey('thread-replies-retry')), + findsNothing, + ); + }); + + testWidgets('shows an incoming nested reply', (tester) async { + final (messages, attempts) = await openDeadlined(tester, mid); + messages.setMessages([ + root, + mid, + liveReply('nested', const [ + ['e', 'root', '', 'root'], + ['e', 'mid', '', 'reply'], + ]), + ]); + await tester.pumpAndSettle(); + expect(find.text('Live nested'), findsOneWidget); + expect(attempts(), 1); + }); + + testWidgets('keeps a sent reply visible after acceptance', ( + tester, + ) async { + final (messages, attempts) = await openDeadlined(tester, root); + final sent = _textMsg( + id: 'sent', + pubkey: 'me', + content: 'My reply', + createdAt: 1300, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + final container = ProviderScope.containerOf( + tester.element(find.byType(ThreadDetailPage)), + ); + const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + container.read(threadLocalRepliesProvider(args).notifier).add(sent); + await tester.pumpAndSettle(); + expect(find.text('My reply'), findsOneWidget); + // Acceptance: the confirmed reply moves from the optimistic overlay + // into the channel cache (cacheConfirmedThreadReplies). + messages.setMessages([root, mid, sent]); + container.read(threadLocalRepliesProvider(args).notifier).confirm({ + 'sent', + }); + await tester.pumpAndSettle(); + expect(find.text('My reply'), findsOneWidget); + expect(attempts(), 1); + }); + }); + testWidgets('opens a nested reply in its direct-parent thread', ( tester, ) async { @@ -14650,6 +14925,23 @@ class _FakeMessagesNotifier extends ChannelMessagesNotifier { } } +/// Counts loads; each settles after mount, like the real network load. +class _RetryCountingMessagesNotifier extends ChannelMessagesNotifier { + _RetryCountingMessagesNotifier(this.nextResult) : super(_channelId); + + AsyncValue> Function() nextResult; + int loads = 0; + + @override + AsyncValue> build() { + loads++; + final result = nextResult(); + if (result is AsyncLoading) return result; + Future(() => state = result); + return const AsyncLoading(); + } +} + class _ErrorMessagesNotifier extends ChannelMessagesNotifier { _ErrorMessagesNotifier() : super(_channelId); diff --git a/mobile/test/features/channels/channel_messages_provider_test.dart b/mobile/test/features/channels/channel_messages_provider_test.dart index 729bf45fc7..bc6f1dab1d 100644 --- a/mobile/test/features/channels/channel_messages_provider_test.dart +++ b/mobile/test/features/channels/channel_messages_provider_test.dart @@ -312,6 +312,57 @@ void main() { ); }); + group('relay deadline owners', () { + RelayException deadline() => + RelayException(503, '{"error":"query timed out"}'); + int threadScans(_RecordingRelaySessionNotifier session) => session + .queryFilters + .where((filter) => filter.extensions.containsKey('depth_limit')) + .length; + NostrEvent reply(int i) => _event( + id: 'reply-$i', + createdAt: 20 + i, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + + test('a window deadline does not fall back to websocket history', () async { + final session = _RecordingRelaySessionNotifier( + queryResults: [deadline()], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await session.subscribed; + await _pumpEventQueue(); + expect(session.operations, ['subscribe', 'query']); + expect( + container.read(channelMessagesProvider(_channelId)).hasError, + true, + ); + }); + + test('a recount deadline ends its retry backoff', () async { + final session = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'root', createdAt: 10), _bounds()], + deadline(), + deadline(), + ], + ); + final container = _buildContainer(session); + addTearDown(container.dispose); + container.listen(channelMessagesProvider(_channelId), (_, _) {}); + await _pumpEventQueue(); + for (var i = 0; i < 300; i++) { + session.emit(reply(i)); + } + await Future.delayed(const Duration(milliseconds: 2000)); + expect(threadScans(session), 1); + }); + }); + test('still loads history when live subscription fails', () async { final relaySession = _RecordingRelaySessionNotifier(failSubscribe: true); final container = _buildContainer(relaySession); diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 3bb1be5beb..e277a4dfb3 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -1632,6 +1632,168 @@ void main() { expect(channels.single.lastMessageAt?.millisecondsSinceEpoch, 20 * 1000); }); + group('latest-message batch failure', () { + Future> fallbackFilters(Object error) async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'general')], + )..messageBatchError = error; + final container = _buildContainer(session: session); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + await _waitUntil(() => session.queryBatches.isNotEmpty); + await Future.delayed(Duration.zero); + return session.historyFilters + .where( + (filter) => + filter.kinds.contains(EventKind.streamMessageV2) && + (filter.tags['#h']?.contains(_channelA) ?? false), + ) + .toList(); + } + + test('a relay deadline never reaches the websocket fallback', () async { + expect( + await fallbackFilters( + RelayException(503, '{"error":"query timed out"}'), + ), + isEmpty, + ); + }); + + test('an ordinary failure still uses the websocket fallback', () async { + expect(await fallbackFilters(Exception('bridge down')), isNotEmpty); + }); + }); + + group('deadline-unavailable channel batches', () { + Object deadline() => RelayException(503, '{"error":"query timed out"}'); + bool isUnread(List batch) => + batch.isNotEmpty && batch.every((filter) => filter.since != null); + NostrEvent message(String id, int createdAt, {bool mention = false}) => + NostrEvent( + id: id, + pubkey: 'alice', + createdAt: createdAt, + kind: EventKind.streamMessageV2, + tags: [ + const ['h', _channelA], + if (mention) const ['p', myPk], + ], + content: 'hi', + sig: 'sig', + ); + + test('a latest-message deadline keeps known timestamps', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'general')], + recentMessages: [message('m1', 30)], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + await _settle(); + session.latestBatchError = deadline(); + await container.read(channelsProvider.notifier).refresh(); + await _settle(); + expect( + container.read(channelsProvider).value!.single.lastMessageAt, + DateTime.fromMillisecondsSinceEpoch(30 * 1000, isUtc: true), + ); + expect(session.messageHistoryCount, 0); + }); + + test( + 'a cold-start latest-message deadline is filled by unread catch-up', + () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'general')], + recentMessages: [message('mention', 30, mention: true)], + )..latestBatchError = deadline(); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + await _waitUntil(() => session.queryBatches.any(isUnread)); + await _settle(); + final notifier = container.read(channelsProvider.notifier); + expect(notifier.observedUnreadEventsByChannel[_channelA]?.keys, [ + 'mention', + ]); + expect( + container.read(channelsProvider).value!.single.lastMessageAt, + DateTime.fromMillisecondsSinceEpoch(30 * 1000, isUtc: true), + ); + expect(session.messageHistoryCount, 0); + }, + ); + + test('unread catch-up never moves lastMessageAt backward', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'general')], + recentMessages: [message('older', 30, mention: true)], + )..pauseNextUnreadCatchUpQuery(); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + await session.nextUnreadCatchUpQueryStarted; + // A newer live event lands while the catch-up is in flight. + session.emit(message('newer', 60, mention: true)); + session.resumePausedUnreadCatchUpQuery(); + await _settle(); + expect( + container + .read(channelsProvider.notifier) + .observedUnreadEventsByChannel[_channelA] + ?.keys, + containsAll(['older', 'newer']), + ); + expect( + container.read(channelsProvider).value!.single.lastMessageAt, + DateTime.fromMillisecondsSinceEpoch(60 * 1000, isUtc: true), + ); + }); + + // HTTP fails ordinarily, then a per-filter websocket fallback times out: + // the batch is unavailable and no later chunk is sent. + test('a websocket fallback deadline makes the batch unavailable', () async { + final ids = [for (var i = 0; i < 6; i++) 'chunk-channel-$i']; + final session = _FakeRelaySession( + memberships: [ + _membership(_channelA, myPk), + for (final id in ids) _membership(id, myPk), + ], + metadata: [ + _meta(id: _channelA, name: 'general'), + for (final id in ids) _meta(id: id, name: id), + ], + recentMessages: [message('m1', 30)], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + await _settle(); + general() => container + .read(channelsProvider) + .value! + .firstWhere((channel) => channel.id == _channelA); + final lastMessageAt = general().lastMessageAt; + expect(lastMessageAt, isNotNull); + session + ..messageBatchError = Exception('bridge down') + ..messageHistoryError = deadline(); + await container.read(channelsProvider.notifier).refresh(); + await _settle(); + // Unavailable, not empty: known timestamps survive. + expect(general().lastMessageAt, lastMessageAt); + // Each batch stops after its first chunk of four filters. + expect(session.messageHistoryCount % 4, 0); + expect(session.messageHistoryCount, lessThan(2 * ids.length)); + }); + }); + test( 'loads all channel timestamps through one batched relay query', () async { @@ -2261,6 +2423,11 @@ class _FakeRelaySession extends RelaySessionNotifier { final List historyFilters = []; final List> queryBatches = []; + Object? messageBatchError; + Object? latestBatchError; + Object? messageHistoryError; + int messageHistoryCount = 0; + final List directoryQueryFilters = []; final List membershipQueryFilters = []; final List subscribeFilters = []; @@ -2536,6 +2703,9 @@ class _FakeRelaySession extends RelaySessionNotifier { // Member metadata query — return only matching `d` tags. return metadata.where((e) => ids.contains(e.getTagValue('d'))).toList(); } + // Per-filter websocket fallback of a message batch. + messageHistoryCount++; + if (messageHistoryError case final error?) throw error; return const []; } @@ -2614,11 +2784,13 @@ class _FakeRelaySession extends RelaySessionNotifier { return filter.until == null ? directorySnapshot : const []; } queryBatches.add(filters); + if (messageBatchError case final error?) throw error; // The unread catch-up is the only batch that carries `since` on every // filter; the latest-message batch leaves it null. Snapshot the messages at // request time so a parked response reflects the scope that asked for it. final isUnreadCatchUp = filters.isNotEmpty && filters.every((filter) => filter.since != null); + if (!isUnreadCatchUp && latestBatchError != null) throw latestBatchError!; final messageSnapshot = List.of(recentMessages); if (isUnreadCatchUp) { // Claim the parked slot so the refresh that follows the switch can run diff --git a/mobile/test/features/forum/forum_widgets_test.dart b/mobile/test/features/forum/forum_widgets_test.dart index 2d22026530..690ada9317 100644 --- a/mobile/test/features/forum/forum_widgets_test.dart +++ b/mobile/test/features/forum/forum_widgets_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -164,6 +166,56 @@ Widget _buildThreadPage({ ); } +class _CountingForumSession extends RelaySessionNotifier { + _CountingForumSession(this.error); + + Object? error; + int fetchCount = 0; + Completer? park; + + /// Forum-surface attempts only: the posts list (45001) or thread replies + /// (`#e`), excluding shared sub-providers such as profiles and emoji. + int forumAttempts = 0; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + fetchCount++; + if (filter.ids == null && + (filter.kinds.contains(45001) || filter.tags.containsKey('#e'))) { + forumAttempts++; + if (park case final park?) await park.future; + } + if (error case final error?) throw error; + return const []; + } +} + +Widget _buildLiveForum(_CountingForumSession session, Widget child) => + ProviderScope( + // Isolate the timers from Riverpod's own error retry. + retry: (_, _) => null, + overrides: [ + relaySessionProvider.overrideWith(() => session), + userCacheProvider.overrideWith(() => _FakeUserCacheNotifier(const {})), + knownAgentPubkeysProvider.overrideWithValue(const {}), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + savedPrefsProvider.overrideWithValue(_testPrefs), + relayClientProvider.overrideWithValue( + RelayClient(baseUrl: 'http://localhost:3000'), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold(body: child), + ), + ); + void main() { setUp(() async { SharedPreferences.setMockInitialValues({}); @@ -194,6 +246,134 @@ void main() { ); }); + group('forum polling after a relay deadline', () { + final deadline = RelayException(503, '{"error":"query timed out"}'); + final surfaces = { + 'posts view (15s)': ( + ForumPostsView(channel: _forumChannel, currentPubkey: 'self'), + const Duration(seconds: 15), + ), + 'thread page (10s)': ( + const ForumThreadPage( + channelId: _channelId, + postEventId: 'post1', + currentPubkey: 'self', + isMember: true, + isArchived: false, + ), + const Duration(seconds: 10), + ), + }; + for (final MapEntry(key: name, value: (surface, interval)) + in surfaces.entries) { + testWidgets('$name pauses on a deadline', (tester) async { + final session = _CountingForumSession(deadline); + await tester.pumpWidget(_buildLiveForum(session, surface)); + await tester.pump(); + final settled = session.fetchCount; + expect(session.forumAttempts, 1); + for (var tick = 0; tick < 4; tick++) { + await tester.pump(interval); + } + expect(session.fetchCount, settled); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('$name keeps polling after an ordinary error', ( + tester, + ) async { + final session = _CountingForumSession(Exception('reset')); + await tester.pumpWidget(_buildLiveForum(session, surface)); + await tester.pump(); + final settled = session.fetchCount; + await tester.pump(interval); + await tester.pump(); + expect(session.fetchCount, greaterThan(settled)); + await tester.pumpWidget(const SizedBox()); + }); + + for (final (kind, error) in [ + ('deadline', deadline as Object), + ('ordinary error', Exception('reset') as Object), + ]) { + testWidgets('$name Retry after a $kind loads once', (tester) async { + final session = _CountingForumSession(error); + await tester.pumpWidget(_buildLiveForum(session, surface)); + await tester.pump(); + await tester.pump(); + final retry = find.byKey(const ValueKey('load-error-retry')); + expect(retry, findsOneWidget); + final before = session.forumAttempts; + session.error = null; + await tester.tap(retry); + await tester.pump(); + await tester.pump(); + expect(session.forumAttempts, before + 1); + // The post list settles empty; the thread page has no fake post, + // so it settles on not-found again (the count shows the reload). + if (surface is ForumPostsView) expect(retry, findsNothing); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('$name hides Retry while a $kind retry is pending', ( + tester, + ) async { + final session = _CountingForumSession(error); + await tester.pumpWidget(_buildLiveForum(session, surface)); + await tester.pump(); + await tester.pump(); + final retry = find.byKey(const ValueKey('load-error-retry')); + expect(retry, findsOneWidget); + final before = session.forumAttempts; + final park = session.park = Completer(); + await tester.tap(retry); + await tester.pump(); + await tester.pump(); + expect(retry, findsNothing); + expect(session.forumAttempts, before + 1); + // The replacement fails the same way and settles on Retry again. + park.complete(); + await tester.pump(); + await tester.pump(); + expect(retry, findsOneWidget); + expect(session.forumAttempts, before + 1); + await tester.pumpWidget(const SizedBox()); + }); + } + + testWidgets('$name shows no Retry while loading', (tester) async { + final session = _CountingForumSession(deadline) + ..park = Completer(); + await tester.pumpWidget(_buildLiveForum(session, surface)); + await tester.pump(); + expect(session.forumAttempts, 1); + expect(find.byKey(const ValueKey('load-error-retry')), findsNothing); + session.park!.complete(); + await tester.pump(); + await tester.pump(); + expect(find.byKey(const ValueKey('load-error-retry')), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('$name reopening retries a deadline', (tester) async { + final session = _CountingForumSession(deadline); + await tester.pumpWidget(_buildLiveForum(session, surface)); + await tester.pump(); + expect(session.forumAttempts, 1); + await tester.pumpWidget(_buildLiveForum(session, const SizedBox())); + await tester.pumpWidget(_buildLiveForum(session, surface)); + await tester.pump(); + await tester.pump(); + expect(session.forumAttempts, 2); + for (var tick = 0; tick < 4; tick++) { + await tester.pump(interval); + } + expect(session.forumAttempts, 2); + await tester.pumpWidget(const SizedBox()); + }); + } + }); + group('ForumPostCard', () { testWidgets('renders author name and content', (tester) async { await tester.pumpWidget( diff --git a/mobile/test/shared/relay/relay_closed_policy_test.dart b/mobile/test/shared/relay/relay_closed_policy_test.dart index ff78ea89ef..d83f658f40 100644 --- a/mobile/test/shared/relay/relay_closed_policy_test.dart +++ b/mobile/test/shared/relay/relay_closed_policy_test.dart @@ -16,6 +16,7 @@ void main() { 'unsupported: filter extension': RelayClosedClass.terminal, 'error: mixed search and channel filter': RelayClosedClass.terminal, 'error: too many subscriptions': RelayClosedClass.terminal, + 'error: query timed out': RelayClosedClass.terminal, 'error: relay temporarily unavailable': RelayClosedClass.retryable, 'subscription closed by relay': RelayClosedClass.retryable, }; diff --git a/mobile/test/shared/relay/relay_deadline_provider_retry_test.dart b/mobile/test/shared/relay/relay_deadline_provider_retry_test.dart new file mode 100644 index 0000000000..c2b53103c0 --- /dev/null +++ b/mobile/test/shared/relay/relay_deadline_provider_retry_test.dart @@ -0,0 +1,226 @@ +import 'dart:async'; + +import 'package:buzz/features/channels/thread_replies_provider.dart'; +import 'package:buzz/features/forum/forum_provider.dart'; +import 'package:buzz/main.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +// --------------------------------------------------------------------------- +// Fake sessions +// --------------------------------------------------------------------------- + +class _DeadlineHttpSession extends RelaySessionNotifier { + int queryCount = 0; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + queryCount++; + // Relay HTTP 503 with JSON body: {"error":"query timed out"} + throw RelayException(503, '{"error":"query timed out"}'); + } +} + +class _DeadlineWsSession extends RelaySessionNotifier { + int historyCount = 0; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + historyCount++; + // WebSocket CLOSED: Exception("error: query timed out") + throw Exception('error: query timed out'); + } +} + +class _OrdinaryErrorSession extends RelaySessionNotifier { + int queryCount = 0; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + queryCount++; + throw RelayException(500, '{"error":"internal server error"}'); + } + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + queryCount++; + throw RelayException(500, '{"error":"internal server error"}'); + } +} + +// --------------------------------------------------------------------------- +// isRelayDeadlineError unit tests +// --------------------------------------------------------------------------- + +void main() { + test('the production root scope uses relayProviderRetry', () async { + SharedPreferences.setMockInitialValues({}); + final scope = buildRootProviderScope( + prefs: await SharedPreferences.getInstance(), + child: const SizedBox(), + ); + expect(scope.retry, same(relayProviderRetry)); + }); + + group('isRelayDeadlineError', () { + test('matches HTTP 503 query timed out', () { + expect( + isRelayDeadlineError( + RelayException(503, '{"error":"query timed out"}'), + ), + isTrue, + ); + }); + + test('matches HTTP 503 with whitespace in body value', () { + expect( + isRelayDeadlineError( + RelayException(503, '{"error":" Query Timed Out "}'), + ), + isTrue, + ); + }); + + test('does not match HTTP 503 with different error', () { + expect( + isRelayDeadlineError( + RelayException(503, '{"error":"service unavailable"}'), + ), + isFalse, + ); + }); + + test('does not match HTTP 503 with non-JSON body', () { + expect(isRelayDeadlineError(RelayException(503, 'plain text')), isFalse); + }); + + test('does not match HTTP 500', () { + expect( + isRelayDeadlineError( + RelayException(500, '{"error":"query timed out"}'), + ), + isFalse, + ); + }); + + test('matches WebSocket CLOSED exception string', () { + expect(isRelayDeadlineError(Exception('error: query timed out')), isTrue); + }); + + test('does not match ordinary exception', () { + expect(isRelayDeadlineError(Exception('connection refused')), isFalse); + }); + + test('does not match TimeoutException', () { + expect( + isRelayDeadlineError( + TimeoutException( + 'Relay history request timed out after 0:00:08.000000', + ), + ), + isFalse, + ); + }); + }); + + // ------------------------------------------------------------------------- + // threadRepliesProvider — HTTP path + // ------------------------------------------------------------------------- + + group('threadRepliesProvider deadline retry suppression', () { + const args = ThreadRepliesArgs(channelId: 'chan', rootId: 'root'); + + test( + 'deadline: settles error in exactly one call and does not rebuild', + () async { + final session = _DeadlineHttpSession(); + final container = ProviderContainer( + retry: relayProviderRetry, + overrides: [relaySessionProvider.overrideWith(() => session)], + ); + addTearDown(container.dispose); + + final sub = container.listen(threadRepliesProvider(args), (_, _) {}); + addTearDown(sub.close); + + await container.pump(); + await Future.delayed(const Duration(milliseconds: 500)); + + expect(container.read(threadRepliesProvider(args)).hasError, isTrue); + expect(session.queryCount, 1); + }, + ); + + test( + 'ordinary error: retries at least once with production retry policy', + () async { + final session = _OrdinaryErrorSession(); + final container = ProviderContainer( + retry: relayProviderRetry, + overrides: [relaySessionProvider.overrideWith(() => session)], + ); + addTearDown(container.dispose); + + final sub = container.listen(threadRepliesProvider(args), (_, _) {}); + addTearDown(sub.close); + + await container.pump(); + await Future.delayed(const Duration(milliseconds: 400)); + + // At least 2 calls means Riverpod issued at least one retry. + expect(session.queryCount, greaterThanOrEqualTo(2)); + }, + ); + }); + + // ------------------------------------------------------------------------- + // forumPostsProvider — WebSocket history path + // ------------------------------------------------------------------------- + + group('forumPostsProvider deadline retry suppression', () { + test( + 'deadline: settles error in exactly one call and does not rebuild', + () async { + final session = _DeadlineWsSession(); + final container = ProviderContainer( + retry: relayProviderRetry, + overrides: [relaySessionProvider.overrideWith(() => session)], + ); + addTearDown(container.dispose); + + final sub = container.listen(forumPostsProvider('chan'), (_, _) {}); + addTearDown(sub.close); + + await container.pump(); + await Future.delayed(const Duration(milliseconds: 500)); + + expect(container.read(forumPostsProvider('chan')).hasError, isTrue); + expect(session.historyCount, 1); + }, + ); + }); +} diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index 413f12a82e..50bdb5201d 100644 --- a/mobile/test/shared/relay/relay_session_test.dart +++ b/mobile/test/shared/relay/relay_session_test.dart @@ -1100,6 +1100,38 @@ void main() { expect(_reqs(socket).where((req) => req[1] == 'l-1'), hasLength(1)); }); + test('a deadline CLOSED retries a live sub but fails history', () async { + final timers = <_ManualTimer>[]; + final socket = _RecordingRelaySocket(); + final statuses = []; + final session = RelaySessionNotifier( + retryTimerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + timers.add(timer); + return timer; + }, + ); + session.debugAttachSocketForTest(socket); + final subscribe = session.subscribeWithStatus( + _channelFilter, + (_) {}, + onStatusChanged: statuses.add, + ); + session.debugHandleMessage(['EOSE', 'l-1']); + await subscribe; + + session.debugHandleMessage(['CLOSED', 'l-1', 'error: query timed out']); + expect(statuses.last, RelaySubscriptionStatus.retrying); + timers.single.fire(); + await Future.delayed(Duration.zero); + expect(_reqs(socket).where((req) => req[1] == 'l-1'), hasLength(2)); + + final history = session.fetchHistory(_channelFilter); + final historyId = _reqs(socket).last[1] as String; + session.debugHandleMessage(['CLOSED', historyId, 'error: query timed out']); + await expectLater(history, throwsA(isA())); + }); + test('unsubscribe and dispose cancel CLOSED retry timers', () async { final timers = <_ManualTimer>[]; final socket = _RecordingRelaySocket(); diff --git a/schema/schema.sql b/schema/schema.sql index fdd382f463..37112cc670 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -278,6 +278,10 @@ CREATE INDEX idx_events_not_before ON events (community_id, not_before) -- EXPLAIN before its work lands (Quinn option A; Max's index-spelling caveat). CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv); +-- e-tag containment (`tags @> '[["e",""]]'`) for the aux closure and #e +-- reads. Mirrors migrations/0004; jsonb_path_ops supports exactly @>. +CREATE INDEX idx_events_tags_gin ON events USING GIN (tags jsonb_path_ops); + -- ── Event mentions ──────────────────────────────────────────────────────────── -- Conformance: "Channel-less global events and DMs" (#p fan-out). The join to -- events MUST carry the community tuple (e.community_id = m.community_id AND diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 32bfa1c94b..ff18a07fd1 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -157,7 +157,7 @@ run_unit_tests() { # Mirror the relay filters from `just test-unit`: the three handler modules, # storage-snapshot helpers, readiness and router unit suites, and the single - # scoped admission regression in state::tests. + # scoped admission regression in state::tests, plus the REQ lifecycle tests. run_test_step "buzz-relay channel authorization tests" \ cargo test -p buzz-relay --lib handlers::channel_authz:: -- --nocapture @@ -170,6 +170,20 @@ run_unit_tests() { run_test_step "buzz-relay storage snapshot tests" \ cargo test -p buzz-relay --lib storage_sweep::tests:: -- --nocapture + # Infra-free REQ subscription-lifecycle tests, by exact name: the rest of + # handlers::req needs a database. + run_test_step "buzz-relay REQ subscription lifecycle tests" \ + cargo test -p buzz-relay --lib -- --exact --nocapture \ + handlers::req::tests::timed_out_historical_read_deregisters_before_closed \ + handlers::req::tests::superseded_timeout_leaves_replacement_intact \ + handlers::req::tests::search_claim_retires_live_and_yields_to_replacement \ + handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner \ + handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim \ + handlers::req::tests::revoke_then_replacement_keeps_replacement_whole \ + handlers::req::tests::claims_after_connection_cleanup_are_refused \ + handlers::req::tests::dropped_terminal_frame_cancels_connection \ + handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection + run_test_step "buzz-relay readiness tests" \ cargo test -p buzz-relay --lib readiness::tests:: -- --nocapture diff --git a/scripts/seed-long-thread.sh b/scripts/seed-long-thread.sh new file mode 100755 index 0000000000..70e0d18297 --- /dev/null +++ b/scripts/seed-long-thread.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Seed one long thread on the local dev relay, for reproducing and eyeballing +# long-thread loading in the apps. +# +# just relay # stock local relay on ws://localhost:3000 +# just seed-long-thread 187 # or: scripts/seed-long-thread.sh [replies] +# +# Targets SEED_RELAY_URL (default: .env RELAY_URL, else ws://localhost:3000) +# and refuses non-loopback relays. Ambient BUZZ_RELAY_URL/BUZZ_PRIVATE_KEY are +# ignored so an agent or shell session can never seed its real relay. Posts +# with SEED_PRIVATE_KEY (default: a fresh throwaway key) into a new open stream +# channel, or into SEED_CHANNEL if that key may post there. +# +# Every write (channel, root, replies, reactions) counts against the relay's +# 60 human messages/min, so writes are paced SEED_DELAY seconds apart (default +# 1.05): the default run takes ~3-4 minutes on a stock relay. Set SEED_DELAY=0 +# when BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN is raised on the relay. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +cd "${REPO_ROOT}" + +if [[ -f ".env" ]]; then + set -o allexport + # shellcheck disable=SC1091 + source .env + set +o allexport +fi + +REPLIES="${1:-187}" +REACTIONS="${SEED_REACTIONS:-20}" +export BUZZ_RELAY_URL="${SEED_RELAY_URL:-${RELAY_URL:-ws://localhost:3000}}" +export BUZZ_PRIVATE_KEY="${SEED_PRIVATE_KEY:-$(openssl rand -hex 32)}" +unset BUZZ_AUTH_TAG +if [[ ! "$BUZZ_RELAY_URL" =~ ^wss?://(localhost|127\.0\.0\.1|\[::1\])(:[0-9]+)?/?$ ]]; then + echo "refusing non-local relay: $BUZZ_RELAY_URL" >&2 + exit 1 +fi +BUZZ="${BUZZ_CLI:-cargo run -q -p buzz-cli --}" +DELAY="${SEED_DELAY:-1.05}" +write() { $BUZZ "$@"; sleep "$DELAY"; } + +channel="${SEED_CHANNEL:-}" +if [[ -z "$channel" ]]; then + channel="$(write channels create --name "long-thread-$$" --type stream --visibility open | jq -er .channel_id)" +fi +root="$(write messages send --channel "$channel" --content "long thread root ($REPLIES replies)" | jq -er .event_id)" + +ids=() +for i in $(seq 1 "$REPLIES"); do + ids+=("$(write messages send --channel "$channel" --reply-to "$root" --content "reply $i" | jq -er .event_id)") +done +if (( REPLIES > 0 )); then + for i in $(seq 1 "$REACTIONS"); do + write reactions add --event "${ids[$(( (i * 7) % REPLIES ))]}" --emoji "👍" >/dev/null + done +fi + +echo "channel=$channel" +echo "root=$root" +echo "replies=$REPLIES reactions=$(( REPLIES > 0 ? REACTIONS : 0 ))" From f1e50be4103addabd497563495e0ffe5bda07ef7 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 28 Sep 2026 16:44:16 -0400 Subject: [PATCH 12/12] test(desktop): wait for channel head refresh before paging thread summary test (#7955) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 ## Summary Main's Desktop Smoke E2E (4) job fails on `scroll-history.spec.ts` ("thread summary badge survives a retained older-history prepend"). This change fixes the test's timing. It does not change app code. When a channel opens, the app starts a live subscription. When that subscription is ready, the app fetches the newest page again, and that fetch replaces the whole loaded history. This test scrolled up before that refresh happened. On slower CI runners, the refresh came after the older page loaded but before it showed on screen. The older rows went away, the reader stayed at the top, and no more pages loaded. All three attempts failed that way. The test now calls `waitForMockChannelHeadReady` before it scrolls, like the other pagination tests in this file already do. That helper waits for the live subscription and its refresh to finish. ### Related issue Main CI failure at `15d44dcc` (https://github.com/block/buzz/actions/runs/36470599751/job/109092076235). The same failure was reported on #7827. ### Testing - Added temporary logs to see the order of events. Locally, the refresh ran after the older page had loaded: the loaded history went from 2 pages back to 1. The test passed only because it checked before the next render. With the wait, the refresh runs first and nothing replaces the loaded pages later. - `playwright test --project=smoke tests/e2e/scroll-history.spec.ts`: 18 passed. This test with `--repeat-each=8`: 8 passed. ### Follow-up (not in this PR) The app has the same race for real users. If someone scrolls up before the live subscription is ready, or during a reconnect, the refresh can drop the older history they loaded. If they are at the top of the list, paging stops until they scroll down and back up. Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- desktop/tests/e2e/scroll-history.spec.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts index 27bf08b731..5313bdaf72 100644 --- a/desktop/tests/e2e/scroll-history.spec.ts +++ b/desktop/tests/e2e/scroll-history.spec.ts @@ -2172,6 +2172,13 @@ test("thread summary badge survives a retained older-history prepend", async ({ await page.getByTestId("channel-deep-history").click(); await expect(page.getByTestId("chat-title")).toHaveText("deep-history"); + // Page only after live admission and its head refresh. That refresh + // replaces the window, so an earlier prepend can be discarded. + await waitForMockChannelHeadReady( + page, + "deep-history", + "feedf00d-0000-4000-8000-000000000007", + ); const timeline = page.getByTestId("message-timeline"); const badgeSelector = '[data-testid="message-thread-summary"][data-thread-head-id="mock-deep-history-599"]';