diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 4e1ab1c7f5..7f89c3e20a 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -437,6 +437,9 @@ pub const KIND_THREAD_SUMMARY: u32 = 39005; /// content = `{has_more, next_cursor}`. The only authority on exhaustion — /// clients must not infer `has_more` from row counts. pub const KIND_WINDOW_BOUNDS: u32 = 39006; +/// Deployment banner overlay: relay-signed active operator banner for this viewer. +/// Content is `{id, severity, text, maxDisplays}` and `d` tag is the banner public id. +pub const KIND_RELAY_BANNER: u32 = 13536; /// Workflow definition (parameterized replaceable, d=workflow_uuid). pub const KIND_WORKFLOW_DEF: u32 = 30620; @@ -694,6 +697,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_NIP29_GROUP_ROLES, KIND_THREAD_SUMMARY, KIND_WINDOW_BOUNDS, + KIND_RELAY_BANNER, KIND_PRESENCE_UPDATE, KIND_TYPING_INDICATOR, KIND_HUDDLE_REACTION, @@ -839,6 +843,7 @@ pub const fn is_relay_only_kind(kind: u32) -> bool { | KIND_DM_VISIBILITY | KIND_THREAD_SUMMARY | KIND_WINDOW_BOUNDS + | KIND_RELAY_BANNER ) } @@ -867,6 +872,7 @@ const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 3062 const _: () = assert!(is_parameterized_replaceable(KIND_PROJECT)); // 30621 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_THREAD_SUMMARY)); // 39005 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_WINDOW_BOUNDS)); // 39006 ∈ 30000–39999 +const _: () = assert!(is_replaceable(KIND_RELAY_BANNER)); // 13536 ∈ 10000–19999 // Compile-time: NIP-34 parameterized replaceable kinds are in the correct range. const _: () = assert!( @@ -936,6 +942,14 @@ mod tests { } } + #[test] + fn relay_banner_is_replaceable_relay_only_not_parameterized() { + assert_eq!(KIND_RELAY_BANNER, 13536); + assert!(is_replaceable(KIND_RELAY_BANNER)); + assert!(!is_parameterized_replaceable(KIND_RELAY_BANNER)); + assert!(is_relay_only_kind(KIND_RELAY_BANNER)); + } + // ── event_is_shared / is_unshared_gated_event ──────────────────────── fn make_event_of_kind(kind: u32, tags: &[&[&str]]) -> nostr::Event { diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index d444d9ce52..4a11131e2e 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -64,8 +64,8 @@ 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, relay_admin_actions, relay_invite, relay_members, relay_operators, reminder, - replaceable, storage_accounting, thread, usage, user, workflow, + reaction, relay_admin_actions, relay_banners, relay_invite, relay_members, relay_operators, + reminder, replaceable, storage_accounting, thread, usage, user, workflow, }; pub use allowlist::AllowlistEntry; @@ -78,6 +78,10 @@ pub use community::{ pub use error::{DbError, Result}; pub use event::{EventQuery, DEFAULT_MAX_PAGE_LIMIT}; pub use reaction::ReactionEventInsertOutcome; +pub use relay_banners::{ + RelayBannerDismissOutcome, RelayBannerRecord, RelayBannerScope, RelayBannerSeverity, + RelayBannerTargetScope, RelayBannerUpsert, RelayBannerViewOutcome, MAX_BANNER_MESSAGE_CHARS, +}; pub use reminder::DueReminder; pub use usage::UsageMetricsLeader; diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index aebf8b9092..e984b2a836 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -490,6 +490,9 @@ mod postgres_tests { "relay_admin_outbox", "relay_operator_audit", "storage_accounting_snapshots", + "relay_banners", + "relay_banner_communities", + "relay_banner_user_state", ] { if normalized[insert_pos..].contains(&format!("'{value}'")) { globals.insert(value.to_owned()); @@ -1828,6 +1831,8 @@ mod postgres_tests { let mut expected_fences = migration.fence_attachments.clone(); expected_fences.remove("product_feedback"); expected_fences.remove("rate_limit_violations"); + expected_fences.insert("relay_banner_communities".to_owned()); + expected_fences.insert("relay_banner_user_state".to_owned()); assert_eq!( expected_fences, schema.fence_attachments, "write-fence attachment targets differ after recovery policy" @@ -2267,6 +2272,9 @@ mod postgres_tests { "relay_admin_actions", "relay_admin_outbox", "relay_operator_audit", + "relay_banners", + "relay_banner_communities", + "relay_banner_user_state", ] { assert_eq!( columns(&desired, table).await, diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index 79739373d0..3df8b25741 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -36,6 +36,8 @@ pub mod push; pub mod reaction; /// HTTP report-resolution enforcement state machine persistence. pub mod relay_admin_actions; +/// Deployment-global relay banner persistence. +pub mod relay_banners; /// Use-limited relay invite persistence (v2 opaque tokens). pub mod relay_invite; /// Relay-level membership persistence (NIP-43). diff --git a/crates/buzz-db/src/store/relay_banners.rs b/crates/buzz-db/src/store/relay_banners.rs new file mode 100644 index 0000000000..df623a17cf --- /dev/null +++ b/crates/buzz-db/src/store/relay_banners.rs @@ -0,0 +1,868 @@ +//! Deployment-global relay banner persistence and per-user state. +//! +//! A deployment has at most one active banner. Delivery is community-scoped at +//! read time: an active banner either targets every community, including future +//! communities, or an explicit non-empty set of community ids. + +use buzz_core::CommunityId; +use buzz_datastore_tracing::datastore_span; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{Postgres, Row as _, Transaction}; +use uuid::Uuid; + +use crate::{Db, Result}; + +/// Maximum relay banner message length in Unicode scalar count. +pub const MAX_BANNER_MESSAGE_CHARS: usize = 2_000; + +/// Relay banner severity values accepted by the backend and emitted to clients. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RelayBannerSeverity { + /// Informational banner. + Info, + /// Warning banner. + Warning, + /// Urgent banner. + Urgent, +} + +impl RelayBannerSeverity { + /// Returns the wire/database representation. + pub const fn as_str(self) -> &'static str { + match self { + Self::Info => "info", + Self::Warning => "warning", + Self::Urgent => "urgent", + } + } + + fn parse(value: String) -> Result { + match value.as_str() { + "info" => Ok(Self::Info), + "warning" => Ok(Self::Warning), + "urgent" => Ok(Self::Urgent), + other => Err(crate::DbError::InvalidData(format!( + "invalid relay banner severity: {other}" + ))), + } + } +} + +/// Public targeting scope value used by the admin/client banner contracts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RelayBannerTargetScope { + /// Banner targets every community. + All, + /// Banner targets selected communities only. + Communities, +} + +impl RelayBannerTargetScope { + /// Returns the wire representation. + pub const fn as_str(self) -> &'static str { + match self { + Self::All => "all", + Self::Communities => "communities", + } + } +} + +/// Targeting scope for an operator-configured relay banner. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RelayBannerScope { + /// Banner applies to every community, including communities created later. + AllCommunities, + /// Banner applies only to this non-empty community set. + Communities(Vec), +} + +/// Input for replacing the deployment's active banner. +#[derive(Debug, Clone)] +pub struct RelayBannerUpsert { + /// Severity/type of the banner. + pub severity: RelayBannerSeverity, + /// Plain-text message, 1..=2000 characters. + pub message: String, + /// Number of successful views each user may receive before suppression. + pub max_displays: i32, + /// Targeting scope. + pub scope: RelayBannerScope, + /// Authenticated operator pubkey performing the write. + pub actor_pubkey: Vec, +} + +/// Stored relay banner plus targeting metadata. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RelayBannerRecord { + /// Monotonic internal banner id. + pub id: i64, + /// Stable public banner id exposed on the wire. + pub public_id: Uuid, + /// Severity/type of the banner. + pub severity: RelayBannerSeverity, + /// Plain-text message. + pub message: String, + /// Number of successful views each user may receive before suppression. + pub max_displays: i32, + /// Whether the banner targets every community. + pub target_all_communities: bool, + /// Explicit community targets when not targeting all communities. + pub community_ids: Vec, + /// Row creation timestamp. + pub created_at: DateTime, + /// Row update timestamp. + pub updated_at: DateTime, + /// Operator pubkey that created the row. + pub created_by: Vec, + /// Disable timestamp, if disabled. + pub disabled_at: Option>, +} + +impl RelayBannerRecord { + /// Returns the public target-scope enum for this banner. + pub const fn target_scope(&self) -> RelayBannerTargetScope { + if self.target_all_communities { + RelayBannerTargetScope::All + } else { + RelayBannerTargetScope::Communities + } + } +} + +/// Result of recording a banner view acknowledgement. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RelayBannerViewOutcome { + /// The display was accepted and consumed one remaining view. + Accepted { + /// Display count after accepting this view. + display_count: i32, + }, + /// The banner was already permanently dismissed by this user. + Dismissed, + /// The user had already exhausted `max_displays`. + Exhausted { + /// Display count already consumed by this user. + display_count: i32, + }, + /// The banner is not active or does not target the request community. + NotEligible, +} + +/// Result of recording a banner dismiss acknowledgement. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RelayBannerDismissOutcome { + /// Dismiss state was present after the call. True means this call wrote it. + Dismissed { + /// Whether this call changed durable state from not-dismissed to dismissed. + changed: bool, + }, + /// The banner is not active or does not target the request community. + NotEligible, +} + +impl RelayBannerUpsert { + fn validate(&self) -> Result<()> { + let trimmed_empty = self.message.trim().is_empty(); + let char_count = self.message.chars().count(); + if trimmed_empty || char_count > MAX_BANNER_MESSAGE_CHARS { + return Err(crate::DbError::InvalidData(format!( + "relay banner message must be 1..={MAX_BANNER_MESSAGE_CHARS} characters" + ))); + } + if self.max_displays < 1 { + return Err(crate::DbError::InvalidData( + "relay banner max_displays must be >= 1".to_owned(), + )); + } + if self.actor_pubkey.len() != 32 { + return Err(crate::DbError::InvalidData( + "relay banner actor pubkey must be 32 bytes".to_owned(), + )); + } + if let RelayBannerScope::Communities(ids) = &self.scope { + if ids.is_empty() { + return Err(crate::DbError::InvalidData( + "relay banner community scope must be non-empty".to_owned(), + )); + } + } + Ok(()) + } +} + +async fn acquire_banner_lock(tx: &mut Transaction<'_, Postgres>) -> Result<()> { + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended('relay_banner_active', 0))") + .execute(&mut **tx) + .await?; + Ok(()) +} + +async fn banner_internal_id_for_public_id( + tx: &mut Transaction<'_, Postgres>, + public_id: Uuid, +) -> Result> { + sqlx::query_scalar::<_, i64>( + r#" + SELECT id + FROM relay_banners + WHERE public_id = $1 + AND disabled_at IS NULL + "#, + ) + .bind(public_id) + .fetch_optional(&mut **tx) + .await + .map_err(Into::into) +} + +async fn banner_targets_community( + tx: &mut Transaction<'_, Postgres>, + banner_id: i64, + community_id: CommunityId, +) -> Result { + let eligible = sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS ( + SELECT 1 + FROM relay_banners b + WHERE b.id = $1 + AND b.disabled_at IS NULL + AND ( + b.target_all_communities + OR EXISTS ( + SELECT 1 + FROM relay_banner_communities bc + WHERE bc.banner_id = b.id + AND bc.community_id = $2 + ) + ) + ) + "#, + ) + .bind(banner_id) + .bind(community_id.as_uuid()) + .fetch_one(&mut **tx) + .await?; + Ok(eligible) +} + +impl Db { + /// Lists active communities for the admin banner community picker. + #[datastore_span(name = "admin_list_banner_communities", system = "postgresql")] + pub async fn admin_list_banner_communities(&self) -> Result> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let rows = sqlx::query( + r#" + SELECT id, host + FROM communities + WHERE archived_at IS NULL + AND deleted_at IS NULL + AND deletion_state = 'active' + ORDER BY lower(host), id + "#, + ) + .fetch_all(&mut *connection) + .await?; + + rows.into_iter() + .map(|row| { + Ok(crate::CommunityRecord { + id: CommunityId::from_uuid(row.try_get("id")?), + host: row.try_get("host")?, + }) + }) + .collect() + } + + /// Lists relay banners, newest first, retaining disabled history. + #[datastore_span(name = "admin_list_relay_banners", system = "postgresql")] + pub async fn admin_list_relay_banners(&self, limit: i64) -> Result> { + let limit = limit.clamp(1, 200); + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let rows = sqlx::query( + r#" + SELECT + b.id, + b.public_id, + b.severity, + b.message, + b.max_displays, + b.target_all_communities, + b.created_by, + b.created_at, + b.updated_at, + b.disabled_at, + COALESCE(array_agg(bc.community_id ORDER BY bc.community_id) + FILTER (WHERE bc.community_id IS NOT NULL), ARRAY[]::uuid[]) AS community_ids + FROM relay_banners b + LEFT JOIN relay_banner_communities bc ON bc.banner_id = b.id + GROUP BY b.id + ORDER BY b.updated_at DESC, b.id DESC + LIMIT $1 + "#, + ) + .bind(limit) + .fetch_all(&mut *connection) + .await?; + rows.into_iter().map(row_to_banner).collect() + } + + /// Returns the currently active deployment banner, if any. + #[datastore_span(name = "admin_get_active_relay_banner", system = "postgresql")] + pub async fn admin_get_active_relay_banner(&self) -> Result> { + let mut items = self.admin_list_active_relay_banners(1).await?; + Ok(items.pop()) + } + + async fn admin_list_active_relay_banners(&self, limit: i64) -> Result> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let rows = sqlx::query( + r#" + SELECT + b.id, + b.public_id, + b.severity, + b.message, + b.max_displays, + b.target_all_communities, + b.created_by, + b.created_at, + b.updated_at, + b.disabled_at, + COALESCE(array_agg(bc.community_id ORDER BY bc.community_id) + FILTER (WHERE bc.community_id IS NOT NULL), ARRAY[]::uuid[]) AS community_ids + FROM relay_banners b + LEFT JOIN relay_banner_communities bc ON bc.banner_id = b.id + WHERE b.disabled_at IS NULL + GROUP BY b.id + ORDER BY b.id DESC + LIMIT $1 + "#, + ) + .bind(limit) + .fetch_all(&mut *connection) + .await?; + rows.into_iter().map(row_to_banner).collect() + } + + /// Replaces the single active deployment banner and returns the new row. + #[datastore_span(name = "admin_upsert_relay_banner", system = "postgresql")] + pub async fn admin_upsert_relay_banner( + &self, + input: RelayBannerUpsert, + ) -> Result { + input.validate()?; + let connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; + acquire_banner_lock(&mut tx).await?; + + sqlx::query( + r#" + UPDATE relay_banners + SET disabled_at = now(), disabled_by = $1, updated_at = now(), updated_by = $1 + WHERE disabled_at IS NULL + "#, + ) + .bind(&input.actor_pubkey) + .execute(&mut *tx) + .await?; + + let target_all = matches!(input.scope, RelayBannerScope::AllCommunities); + let row = sqlx::query( + r#" + INSERT INTO relay_banners + (severity, message, max_displays, target_all_communities, created_by, updated_by) + VALUES ($1, $2, $3, $4, $5, $5) + RETURNING id, public_id, severity, message, max_displays, target_all_communities, + created_by, created_at, updated_at, disabled_at + "#, + ) + .bind(input.severity.as_str()) + .bind(&input.message) + .bind(input.max_displays) + .bind(target_all) + .bind(&input.actor_pubkey) + .fetch_one(&mut *tx) + .await?; + let banner_id: i64 = row.try_get("id")?; + + let community_ids = match input.scope { + RelayBannerScope::AllCommunities => Vec::new(), + RelayBannerScope::Communities(ids) => { + for community_id in &ids { + sqlx::query( + "INSERT INTO relay_banner_communities (banner_id, community_id) VALUES ($1, $2)", + ) + .bind(banner_id) + .bind(community_id.as_uuid()) + .execute(&mut *tx) + .await?; + } + ids + } + }; + + tx.commit().await?; + Ok(RelayBannerRecord { + id: banner_id, + public_id: row.try_get("public_id")?, + severity: RelayBannerSeverity::parse(row.try_get("severity")?)?, + message: row.try_get("message")?, + max_displays: row.try_get("max_displays")?, + target_all_communities: row.try_get("target_all_communities")?, + community_ids, + created_by: row.try_get("created_by")?, + created_at: row.try_get("created_at")?, + updated_at: row.try_get("updated_at")?, + disabled_at: row.try_get("disabled_at")?, + }) + } + + /// Disables the currently active deployment banner, preserving history and state. + #[datastore_span(name = "admin_disable_active_relay_banner", system = "postgresql")] + pub async fn admin_disable_active_relay_banner(&self, actor_pubkey: &[u8]) -> Result { + let connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; + acquire_banner_lock(&mut tx).await?; + let result = sqlx::query( + r#" + UPDATE relay_banners + SET disabled_at = now(), disabled_by = $1, updated_at = now(), updated_by = $1 + WHERE disabled_at IS NULL + "#, + ) + .bind(actor_pubkey) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(result.rows_affected() > 0) + } + + /// Resolves the active banner eligible for this user in this community. + #[datastore_span(name = "active_relay_banner_for_user", system = "postgresql")] + pub async fn active_relay_banner_for_user( + &self, + community_id: CommunityId, + pubkey: &[u8], + ) -> Result> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let row = sqlx::query( + r#" + SELECT + b.id, + b.public_id, + b.severity, + b.message, + b.max_displays, + b.target_all_communities, + b.created_by, + b.created_at, + b.updated_at, + b.disabled_at, + COALESCE(array_agg(bc_all.community_id ORDER BY bc_all.community_id) + FILTER (WHERE bc_all.community_id IS NOT NULL), ARRAY[]::uuid[]) AS community_ids + FROM relay_banners b + LEFT JOIN relay_banner_communities bc_all ON bc_all.banner_id = b.id + LEFT JOIN relay_banner_user_state us + ON us.banner_id = b.id + AND us.community_id = $1 + AND us.pubkey = $2 + WHERE b.disabled_at IS NULL + AND (b.target_all_communities OR EXISTS ( + SELECT 1 + FROM relay_banner_communities bc + WHERE bc.banner_id = b.id + AND bc.community_id = $1 + )) + AND us.dismissed_at IS NULL + AND COALESCE(us.display_count, 0) < b.max_displays + GROUP BY b.id, us.display_count, us.dismissed_at + ORDER BY b.id DESC + LIMIT 1 + "#, + ) + .bind(community_id.as_uuid()) + .bind(pubkey) + .fetch_optional(&mut *connection) + .await?; + row.map(row_to_banner).transpose() + } + + /// Records a successful client render/view acknowledgement. + #[datastore_span(name = "ack_relay_banner_view", system = "postgresql")] + pub async fn ack_relay_banner_view( + &self, + community_id: CommunityId, + public_id: Uuid, + pubkey: &[u8], + ) -> Result { + let connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; + let Some(banner_id) = banner_internal_id_for_public_id(&mut tx, public_id).await? else { + tx.rollback().await?; + return Ok(RelayBannerViewOutcome::NotEligible); + }; + if !banner_targets_community(&mut tx, banner_id, community_id).await? { + tx.rollback().await?; + return Ok(RelayBannerViewOutcome::NotEligible); + } + + let row = sqlx::query( + r#" + INSERT INTO relay_banner_user_state + (banner_id, community_id, pubkey, display_count, first_viewed_at, last_viewed_at) + SELECT b.id, $2, $3, 1, now(), now() + FROM relay_banners b + WHERE b.id = $1 + AND b.disabled_at IS NULL + AND b.max_displays >= 1 + ON CONFLICT (banner_id, community_id, pubkey) DO UPDATE SET + display_count = relay_banner_user_state.display_count + 1, + first_viewed_at = COALESCE(relay_banner_user_state.first_viewed_at, now()), + last_viewed_at = now(), + updated_at = now() + WHERE relay_banner_user_state.dismissed_at IS NULL + AND relay_banner_user_state.display_count < ( + SELECT max_displays FROM relay_banners WHERE id = $1 AND disabled_at IS NULL + ) + RETURNING display_count + "#, + ) + .bind(banner_id) + .bind(community_id.as_uuid()) + .bind(pubkey) + .fetch_optional(&mut *tx) + .await?; + + let outcome = if let Some(row) = row { + RelayBannerViewOutcome::Accepted { + display_count: row.try_get("display_count")?, + } + } else { + let state = sqlx::query( + r#" + SELECT us.display_count, us.dismissed_at IS NOT NULL AS dismissed + FROM relay_banner_user_state us + WHERE us.banner_id = $1 AND us.community_id = $2 AND us.pubkey = $3 + "#, + ) + .bind(banner_id) + .bind(community_id.as_uuid()) + .bind(pubkey) + .fetch_optional(&mut *tx) + .await?; + match state { + Some(row) if row.try_get::("dismissed")? => { + RelayBannerViewOutcome::Dismissed + } + Some(row) => RelayBannerViewOutcome::Exhausted { + display_count: row.try_get("display_count")?, + }, + None => RelayBannerViewOutcome::NotEligible, + } + }; + tx.commit().await?; + Ok(outcome) + } + + /// Permanently dismisses a banner for a user in one community. + #[datastore_span(name = "ack_relay_banner_dismiss", system = "postgresql")] + pub async fn ack_relay_banner_dismiss( + &self, + community_id: CommunityId, + public_id: Uuid, + pubkey: &[u8], + ) -> Result { + let connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; + let Some(banner_id) = banner_internal_id_for_public_id(&mut tx, public_id).await? else { + tx.rollback().await?; + return Ok(RelayBannerDismissOutcome::NotEligible); + }; + if !banner_targets_community(&mut tx, banner_id, community_id).await? { + tx.rollback().await?; + return Ok(RelayBannerDismissOutcome::NotEligible); + } + + let changed = sqlx::query_scalar::<_, bool>( + r#" + WITH inserted AS ( + INSERT INTO relay_banner_user_state + (banner_id, community_id, pubkey, dismissed_at) + VALUES ($1, $2, $3, now()) + ON CONFLICT (banner_id, community_id, pubkey) DO NOTHING + RETURNING TRUE AS changed + ), updated AS ( + UPDATE relay_banner_user_state + SET dismissed_at = now(), updated_at = now() + WHERE banner_id = $1 + AND community_id = $2 + AND pubkey = $3 + AND dismissed_at IS NULL + AND NOT EXISTS (SELECT 1 FROM inserted) + RETURNING TRUE AS changed + ) + SELECT COALESCE( + (SELECT changed FROM inserted), + (SELECT changed FROM updated), + FALSE + ) + "#, + ) + .bind(banner_id) + .bind(community_id.as_uuid()) + .bind(pubkey) + .fetch_one(&mut *tx) + .await?; + tx.commit().await?; + Ok(RelayBannerDismissOutcome::Dismissed { changed }) + } +} + +fn row_to_banner(row: sqlx::postgres::PgRow) -> Result { + let community_uuids: Vec = row.try_get("community_ids")?; + Ok(RelayBannerRecord { + id: row.try_get("id")?, + public_id: row.try_get("public_id")?, + severity: RelayBannerSeverity::parse(row.try_get("severity")?)?, + message: row.try_get("message")?, + max_displays: row.try_get("max_displays")?, + target_all_communities: row.try_get("target_all_communities")?, + community_ids: community_uuids + .into_iter() + .map(CommunityId::from_uuid) + .collect(), + created_by: row.try_get("created_by")?, + created_at: row.try_get("created_at")?, + updated_at: row.try_get("updated_at")?, + disabled_at: row.try_get("disabled_at")?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::PgPool; + + async fn setup_db() -> Db { + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect to test DB"); + Db::from_pool(pool) + } + + async fn make_community(pool: &PgPool) -> CommunityId { + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(format!("banner-{}.example", id.simple())) + .execute(pool) + .await + .expect("insert community"); + CommunityId::from_uuid(id) + } + + fn actor(byte: u8) -> Vec { + vec![byte; 32] + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn upsert_replaces_single_active_banner_and_retains_history() { + let db = setup_db().await; + let first = db + .admin_upsert_relay_banner(RelayBannerUpsert { + severity: RelayBannerSeverity::Info, + message: "first".to_owned(), + max_displays: 1, + scope: RelayBannerScope::AllCommunities, + actor_pubkey: actor(1), + }) + .await + .expect("insert first banner"); + let second = db + .admin_upsert_relay_banner(RelayBannerUpsert { + severity: RelayBannerSeverity::Warning, + message: "second".to_owned(), + max_displays: 2, + scope: RelayBannerScope::AllCommunities, + actor_pubkey: actor(2), + }) + .await + .expect("replace banner"); + + let active = db + .admin_get_active_relay_banner() + .await + .expect("get active") + .expect("active banner"); + assert_eq!(active.id, second.id); + let all = db.admin_list_relay_banners(10).await.expect("list banners"); + assert_eq!(all.len(), 2); + assert!(all + .iter() + .any(|b| b.id == first.id && b.disabled_at.is_some())); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn community_scope_filters_delivery() { + let db = setup_db().await; + let included = make_community(&db.pool).await; + let excluded = make_community(&db.pool).await; + let user = actor(3); + let banner = db + .admin_upsert_relay_banner(RelayBannerUpsert { + severity: RelayBannerSeverity::Urgent, + message: "scoped".to_owned(), + max_displays: 1, + scope: RelayBannerScope::Communities(vec![included]), + actor_pubkey: actor(4), + }) + .await + .expect("insert scoped banner"); + + assert_eq!(banner.community_ids, vec![included]); + assert!(db + .active_relay_banner_for_user(included, &user) + .await + .expect("included lookup") + .is_some()); + assert!(db + .active_relay_banner_for_user(excluded, &user) + .await + .expect("excluded lookup") + .is_none()); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn view_ack_consumes_display_and_then_exhausts() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let user = actor(5); + let banner = db + .admin_upsert_relay_banner(RelayBannerUpsert { + severity: RelayBannerSeverity::Info, + message: "limited".to_owned(), + max_displays: 1, + scope: RelayBannerScope::AllCommunities, + actor_pubkey: actor(6), + }) + .await + .expect("insert banner"); + + assert_eq!( + db.active_relay_banner_for_user(community, &user) + .await + .expect("lookup before view") + .as_ref() + .map(|active| active.id), + Some(banner.id), + "delivery lookup must not consume max_displays before client view ack" + ); + assert_eq!( + db.active_relay_banner_for_user(community, &user) + .await + .expect("second lookup before view") + .as_ref() + .map(|active| active.id), + Some(banner.id), + "repeated delivery lookups without view ack must not exhaust max_displays" + ); + assert_eq!( + db.ack_relay_banner_view(community, banner.public_id, &user) + .await + .expect("first view"), + RelayBannerViewOutcome::Accepted { display_count: 1 } + ); + assert_eq!( + db.ack_relay_banner_view(community, banner.public_id, &user) + .await + .expect("second view"), + RelayBannerViewOutcome::Exhausted { display_count: 1 } + ); + assert!(db + .active_relay_banner_for_user(community, &user) + .await + .expect("post-exhaust lookup") + .is_none()); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn dismiss_permanently_suppresses_banner() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let user = actor(7); + let banner = db + .admin_upsert_relay_banner(RelayBannerUpsert { + severity: RelayBannerSeverity::Info, + message: "dismiss me".to_owned(), + max_displays: 3, + scope: RelayBannerScope::AllCommunities, + actor_pubkey: actor(8), + }) + .await + .expect("insert banner"); + + assert_eq!( + db.ack_relay_banner_dismiss(community, banner.public_id, &user) + .await + .expect("dismiss"), + RelayBannerDismissOutcome::Dismissed { changed: true } + ); + assert_eq!( + db.ack_relay_banner_dismiss(community, banner.public_id, &user) + .await + .expect("second dismiss"), + RelayBannerDismissOutcome::Dismissed { changed: false } + ); + assert_eq!( + db.ack_relay_banner_view(community, banner.public_id, &user) + .await + .expect("view after dismiss"), + RelayBannerViewOutcome::Dismissed + ); + assert!(db + .active_relay_banner_for_user(community, &user) + .await + .expect("lookup after dismiss") + .is_none()); + } +} diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index 19f2153b95..25ddf26155 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -60,9 +60,13 @@ pub fn router(state: Arc) -> Router { .route("/operators", get(list_operators)) .route("/operators/{pubkey}", put(upsert_operator)) .route("/operators/{pubkey}", delete(delete_operator)) + .route("/communities", get(list_banner_communities)) + .route("/banners", get(banners)) + .route("/banners/current", put(upsert_banner)) + .route("/banners/current", delete(disable_banner)) .layer(middleware::from_fn(security_headers)) - // Mutation routes carry a JSON body (max ~4 KB); read-only routes have no body. - .layer(RequestBodyLimitLayer::new(4096)) + // Mutation routes carry a JSON body; banner text may be up to 2,000 chars. + .layer(RequestBodyLimitLayer::new(8192)) .with_state(state) } @@ -406,6 +410,223 @@ async fn feedback_attachment( Ok(response) } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct BannerCommunityResponse { + id: Uuid, + host: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct AdminBannerResponse { + id: String, + severity: &'static str, + text: String, + max_displays: i32, + target_scope: &'static str, + community_ids: Vec, + created_by: String, + created_at: DateTime, + disabled_at: Option>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct BannersResponse { + active: Option, + history: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct BannerQuery { + limit: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct UpsertBannerBody { + severity: buzz_db::RelayBannerSeverity, + text: String, + max_displays: i32, + target_scope: buzz_db::RelayBannerTargetScope, + community_ids: Option>, +} + +impl From for AdminBannerResponse { + fn from(value: buzz_db::RelayBannerRecord) -> Self { + let target_scope = value.target_scope().as_str(); + Self { + id: value.public_id.to_string(), + severity: value.severity.as_str(), + text: value.message, + max_displays: value.max_displays, + target_scope, + community_ids: value + .community_ids + .into_iter() + .map(|id| *id.as_uuid()) + .collect(), + created_by: hex::encode(value.created_by), + created_at: value.created_at, + disabled_at: value.disabled_at, + } + } +} + +async fn list_banner_communities( + State(state): State>, + uri: Uri, + headers: HeaderMap, +) -> Result>, ApiError> { + let principal = authorize( + &state, + &headers, + uri.path_and_query() + .map_or_else(|| uri.path(), |pq| pq.as_str()), + "GET", + None, + ) + .await?; + let principal = require_mutation_principal(principal)?; + require_operator(&principal)?; + let communities = state.db.admin_list_banner_communities().await?; + Ok(Json( + communities + .into_iter() + .map(|community| BannerCommunityResponse { + id: *community.id.as_uuid(), + host: community.host, + }) + .collect(), + )) +} + +async fn banners( + State(state): State>, + uri: Uri, + headers: HeaderMap, + Query(query): Query, +) -> Result, ApiError> { + let principal = authorize( + &state, + &headers, + uri.path_and_query() + .map_or_else(|| uri.path(), |pq| pq.as_str()), + "GET", + None, + ) + .await?; + let principal = require_mutation_principal(principal)?; + require_operator(&principal)?; + let history = state + .db + .admin_list_relay_banners(limit(query.limit)?) + .await?; + let active = history + .iter() + .find(|banner| banner.disabled_at.is_none()) + .cloned() + .map(AdminBannerResponse::from); + Ok(Json(BannersResponse { + active, + history: history.into_iter().map(AdminBannerResponse::from).collect(), + })) +} + +async fn upsert_banner( + State(state): State>, + uri: Uri, + headers: HeaderMap, + body_bytes: Bytes, +) -> Result, ApiError> { + let principal = require_mutation_principal( + authorize( + &state, + &headers, + uri.path_and_query() + .map_or_else(|| uri.path(), |pq| pq.as_str()), + "PUT", + Some(&body_bytes), + ) + .await?, + )?; + require_operator(&principal)?; + let body: UpsertBannerBody = serde_json::from_slice(&body_bytes) + .map_err(|_| ApiError::bad_request("invalid_body", "invalid JSON body"))?; + let scope = if body.target_scope == buzz_db::RelayBannerTargetScope::All { + if body + .community_ids + .as_ref() + .is_some_and(|ids| !ids.is_empty()) + { + return Err(ApiError::bad_request( + "invalid_scope", + "communityIds must be empty when targetScope is all", + )); + } + buzz_db::RelayBannerScope::AllCommunities + } else { + let ids = body.community_ids.unwrap_or_default(); + if ids.is_empty() { + return Err(ApiError::bad_request( + "invalid_scope", + "communityIds must be non-empty when targetScope is communities", + )); + } + buzz_db::RelayBannerScope::Communities( + ids.into_iter() + .map(buzz_core::CommunityId::from_uuid) + .collect(), + ) + }; + + let banner = state + .db + .admin_upsert_relay_banner(buzz_db::RelayBannerUpsert { + severity: body.severity, + message: body.text, + max_displays: body.max_displays, + scope, + actor_pubkey: principal.pubkey.to_vec(), + }) + .await + .map_err(map_banner_db_error)?; + Ok(Json(AdminBannerResponse::from(banner))) +} + +async fn disable_banner( + State(state): State>, + uri: Uri, + headers: HeaderMap, +) -> Result, ApiError> { + let principal = require_mutation_principal( + authorize( + &state, + &headers, + uri.path_and_query() + .map_or_else(|| uri.path(), |pq| pq.as_str()), + "DELETE", + None, + ) + .await?, + )?; + require_operator(&principal)?; + let disabled = state + .db + .admin_disable_active_relay_banner(&principal.pubkey) + .await?; + Ok(Json(serde_json::json!({ "disabled": disabled }))) +} + +fn map_banner_db_error(error: buzz_db::DbError) -> ApiError { + match error { + buzz_db::DbError::InvalidData(message) => ApiError::bad_request("invalid_banner", &message), + _ => ApiError::internal(), + } +} + // ── Phase 2: Report resolution ──────────────────────────────────────────────── /// Request body for POST /reports/{id}/resolve. diff --git a/crates/buzz-relay/src/api/banners.rs b/crates/buzz-relay/src/api/banners.rs new file mode 100644 index 0000000000..8a68027420 --- /dev/null +++ b/crates/buzz-relay/src/api/banners.rs @@ -0,0 +1,373 @@ +//! Client-facing relay banner API. + +use std::sync::Arc; + +use axum::{ + body::Bytes, + extract::{Path, State}, + http::HeaderMap, + response::Json, +}; +use buzz_core::TenantContext; +use serde::Serialize; +use serde_json::Value; + +use crate::api::{api_error, bridge, internal_error, relay_members}; +use crate::state::AppState; + +/// Client route for retrieving the active banner for the authenticated user. +pub(crate) const BANNER_ACTIVE_PATH: &str = "/api/banners/current"; +/// Client route for acknowledging a successful banner render/view. +pub(crate) const BANNER_VIEW_ROUTE: &str = "/api/banners/{banner_id}/view"; +/// Client route for dismissing a banner. +pub(crate) const BANNER_DISMISS_ROUTE: &str = "/api/banners/{banner_id}/dismiss"; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BannerResponse { + /// Public banner id. + pub id: String, + /// Banner event kind clients may also query over Nostr. + pub kind: u32, + /// Banner severity/type. + pub severity: &'static str, + /// Plain-text banner text. + pub text: String, + /// Number of successful views each user may receive before suppression. + pub max_displays: i32, + /// Every v1 severity is dismissible. + pub dismissible: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BannerAckResponse { + status: &'static str, +} + +impl From for BannerResponse { + fn from(value: buzz_db::RelayBannerRecord) -> Self { + Self { + id: value.public_id.to_string(), + kind: buzz_core::kind::KIND_RELAY_BANNER, + severity: value.severity.as_str(), + text: value.message, + max_displays: value.max_displays, + dismissible: true, + } + } +} + +pub(crate) fn banner_event( + relay_keypair: &nostr::Keys, + banner: &buzz_db::RelayBannerRecord, +) -> Result { + let id = banner.public_id.to_string(); + let scope = if banner.target_all_communities { + "all" + } else { + "communities" + }; + let content = serde_json::json!({ + "id": id, + "severity": banner.severity.as_str(), + "text": banner.message, + "maxDisplays": banner.max_displays, + "dismissible": true, + }) + .to_string(); + let tags = vec![ + nostr::Tag::parse(["d", id.as_str()]).map_err(|e| e.to_string())?, + nostr::Tag::parse(["scope", scope]).map_err(|e| e.to_string())?, + ]; + nostr::EventBuilder::new( + nostr::Kind::Custom(buzz_core::kind::KIND_RELAY_BANNER as u16), + content, + ) + .tags(tags) + .sign_with_keys(relay_keypair) + .map_err(|e| e.to_string()) +} + +pub(crate) async fn active_banner_event_for_user( + state: &AppState, + tenant: &TenantContext, + pubkey_bytes: &[u8], +) -> Result, String> { + let Some(banner) = state + .db + .active_relay_banner_for_user(tenant.community(), pubkey_bytes) + .await + .map_err(|e| e.to_string())? + else { + return Ok(None); + }; + banner_event(&state.relay_keypair, &banner).map(Some) +} + +pub(crate) async fn get_active_banner( + State(state): State>, + headers: HeaderMap, +) -> Result>, (axum::http::StatusCode, Json)> { + let (tenant, pubkey, event_id, signed_created_at) = + authenticate_client_request(&state, &headers, "GET", BANNER_ACTIVE_PATH, None, false) + .await?; + bridge::check_nip98_replay(&state, &tenant, event_id).await?; + enforce_member(&state, &tenant, &headers, &pubkey, signed_created_at).await?; + + let banner = state + .db + .active_relay_banner_for_user(tenant.community(), pubkey.as_bytes()) + .await + .map_err(|e| internal_error(&format!("banner lookup: {e}")))? + .map(BannerResponse::from); + Ok(Json(banner)) +} + +pub(crate) async fn ack_banner_view( + State(state): State>, + Path(banner_id): Path, + headers: HeaderMap, + body: Bytes, +) -> Result, (axum::http::StatusCode, Json)> { + let (tenant, pubkey, event_id, signed_created_at) = authenticate_client_request( + &state, + &headers, + "POST", + &format!("/api/banners/{banner_id}/view"), + Some(&body), + true, + ) + .await?; + bridge::check_nip98_replay(&state, &tenant, event_id).await?; + enforce_member(&state, &tenant, &headers, &pubkey, signed_created_at).await?; + if !body.is_empty() { + return Err(non_empty_ack_body_error()); + } + match state + .db + .ack_relay_banner_view(tenant.community(), banner_id, pubkey.as_bytes()) + .await + .map_err(|e| internal_error(&format!("banner view ack: {e}")))? + { + buzz_db::RelayBannerViewOutcome::Accepted { .. } => { + metrics::counter!( + "buzz_relay_banner_views_total", + "community" => tenant.host().to_owned() + ) + .increment(1); + Ok(Json(BannerAckResponse { status: "accepted" })) + } + buzz_db::RelayBannerViewOutcome::Dismissed => Ok(Json(BannerAckResponse { + status: "dismissed", + })), + buzz_db::RelayBannerViewOutcome::Exhausted { .. } => Ok(Json(BannerAckResponse { + status: "exhausted", + })), + buzz_db::RelayBannerViewOutcome::NotEligible => Err(api_error( + axum::http::StatusCode::NOT_FOUND, + "banner not eligible", + )), + } +} + +pub(crate) async fn ack_banner_dismiss( + State(state): State>, + Path(banner_id): Path, + headers: HeaderMap, + body: Bytes, +) -> Result, (axum::http::StatusCode, Json)> { + let (tenant, pubkey, event_id, signed_created_at) = authenticate_client_request( + &state, + &headers, + "POST", + &format!("/api/banners/{banner_id}/dismiss"), + Some(&body), + true, + ) + .await?; + bridge::check_nip98_replay(&state, &tenant, event_id).await?; + enforce_member(&state, &tenant, &headers, &pubkey, signed_created_at).await?; + if !body.is_empty() { + return Err(non_empty_ack_body_error()); + } + match state + .db + .ack_relay_banner_dismiss(tenant.community(), banner_id, pubkey.as_bytes()) + .await + .map_err(|e| internal_error(&format!("banner dismiss ack: {e}")))? + { + buzz_db::RelayBannerDismissOutcome::Dismissed { changed } => { + if changed { + metrics::counter!( + "buzz_relay_banner_dismissals_total", + "community" => tenant.host().to_owned() + ) + .increment(1); + } + Ok(Json(BannerAckResponse { + status: "dismissed", + })) + } + buzz_db::RelayBannerDismissOutcome::NotEligible => Err(api_error( + axum::http::StatusCode::NOT_FOUND, + "banner not eligible", + )), + } +} + +async fn authenticate_client_request( + state: &AppState, + headers: &HeaderMap, + method: &str, + path: &str, + body: Option<&[u8]>, + require_payload: bool, +) -> Result< + (TenantContext, nostr::PublicKey, [u8; 32], Option), + (axum::http::StatusCode, Json), +> { + let raw_host = headers + .get(axum::http::header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let tenant = crate::tenant::bind_community(&state.db, raw_host) + .await + .map_err(|_| { + api_error( + axum::http::StatusCode::NOT_FOUND, + "relay: no community is configured for this host", + ) + })?; + let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); + let verified = bridge::verify_bridge_auth_with_options( + headers, + method, + &url, + body, + state.config.require_auth_token, + require_payload, + )?; + bridge::enforce_http_admission(state, &tenant, &verified.pubkey).await?; + Ok(( + tenant, + verified.pubkey, + verified.event_id_bytes, + verified.signed_created_at, + )) +} + +fn non_empty_ack_body_error() -> (axum::http::StatusCode, Json) { + api_error( + axum::http::StatusCode::BAD_REQUEST, + "banner acknowledgement body must be empty", + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn banner_record(target_all_communities: bool) -> buzz_db::RelayBannerRecord { + buzz_db::RelayBannerRecord { + id: 42, + public_id: uuid::Uuid::from_u128(0x12345678123456781234567812345678), + severity: buzz_db::RelayBannerSeverity::Warning, + message: "scheduled maintenance".to_owned(), + max_displays: 3, + target_all_communities, + community_ids: Vec::new(), + created_at: chrono::DateTime::UNIX_EPOCH, + updated_at: chrono::DateTime::UNIX_EPOCH, + created_by: vec![7; 32], + disabled_at: None, + } + } + + #[test] + fn banner_event_uses_published_contract() { + let keys = nostr::Keys::generate(); + let banner = banner_record(false); + let event = banner_event(&keys, &banner).expect("banner event"); + + assert_eq!( + event.kind.as_u16() as u32, + buzz_core::kind::KIND_RELAY_BANNER + ); + assert_eq!(buzz_core::kind::KIND_RELAY_BANNER, 13536); + assert_eq!( + event + .tags + .iter() + .find(|tag| tag.kind().to_string() == "d") + .and_then(|tag| tag.content()), + Some(banner.public_id.to_string().as_str()) + ); + assert_eq!( + event + .tags + .iter() + .find(|tag| tag.kind().to_string() == "scope") + .and_then(|tag| tag.content()), + Some("communities") + ); + + let content: serde_json::Value = + serde_json::from_str(&event.content).expect("banner JSON content"); + assert_eq!(content["id"], banner.public_id.to_string()); + assert_eq!(content["severity"], "warning"); + assert_eq!(content["text"], "scheduled maintenance"); + assert_eq!(content["maxDisplays"], 3); + assert_eq!(content["dismissible"], true); + assert!(content.get("message").is_none()); + } + + #[test] + fn all_community_banner_event_has_all_scope() { + let keys = nostr::Keys::generate(); + let banner = banner_record(true); + let event = banner_event(&keys, &banner).expect("banner event"); + + assert_eq!( + event + .tags + .iter() + .find(|tag| tag.kind().to_string() == "scope") + .and_then(|tag| tag.content()), + Some("all") + ); + } + + #[test] + fn banner_ack_routes_are_uuid_path_routes() { + assert_eq!(BANNER_VIEW_ROUTE, "/api/banners/{banner_id}/view"); + assert_eq!(BANNER_DISMISS_ROUTE, "/api/banners/{banner_id}/dismiss"); + } + + #[test] + fn non_empty_ack_body_is_rejected() { + let (status, body) = non_empty_ack_body_error(); + + assert_eq!(status, axum::http::StatusCode::BAD_REQUEST); + assert_eq!(body["error"], "banner acknowledgement body must be empty"); + } +} + +async fn enforce_member( + state: &AppState, + tenant: &TenantContext, + headers: &HeaderMap, + pubkey: &nostr::PublicKey, + signed_created_at: Option, +) -> Result<(), (axum::http::StatusCode, Json)> { + let auth_tag = relay_members::extract_auth_tag_header(headers); + relay_members::enforce_relay_membership( + state, + tenant.community(), + pubkey.as_bytes(), + auth_tag, + signed_created_at, + ) + .await + .map(|_| ()) +} diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 37c549610d..041a730bba 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1182,6 +1182,27 @@ async fn query_events_authed( return presence_result.map(|events| Json(Value::Array(events))); } + if filters_are_relay_banner_only(&filters) { + let mut events = Vec::new(); + if let Some(event) = + crate::api::banners::active_banner_event_for_user(state, tenant, &pubkey_bytes) + .await + .map_err(|e| internal_error(&format!("banner lookup: {e}")))? + { + let stored = + buzz_core::StoredEvent::with_received_at(event, chrono::Utc::now(), None, true); + if filters.iter().any(|filter| { + buzz_core::filter::filters_match(std::slice::from_ref(filter), &stored) + }) { + events.push( + serde_json::to_value(&stored.event) + .map_err(|e| internal_error(&format!("banner serialize: {e}")))?, + ); + } + } + return Ok(Json(Value::Array(events))); + } + let mut events: Vec = Vec::new(); let mut handled: std::collections::HashSet = std::collections::HashSet::new(); @@ -2329,6 +2350,18 @@ async fn synthesize_presence( Some(Ok(events)) } +fn filters_are_relay_banner_only(filters: &[nostr::Filter]) -> bool { + !filters.is_empty() + && filters.iter().all(|filter| { + filter.kinds.as_ref().is_some_and(|kinds| { + kinds.len() == 1 + && kinds + .iter() + .all(|kind| kind.as_u16() as u32 == buzz_core::kind::KIND_RELAY_BANNER) + }) + }) +} + // ── Moderation queue reads (L6 — Quinn) ─────────────────────────────────────── // // Mod-only structured rows (`moderation_reports`/`moderation_actions`/ diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 5745b8d4e5..8b9bd280ad 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -1,6 +1,7 @@ //! HTTP API — media, git, NIP-05, and the Nostr HTTP bridge. pub mod admin; +pub mod banners; pub mod bridge; pub mod events; pub mod gifs; diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 97d252cb7e..ded8348a58 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -8,7 +8,7 @@ use tracing::{debug, warn}; use buzz_core::filter::filters_match; use buzz_core::kind::{ is_unshared_gated_event, AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC, - KIND_DM_VISIBILITY, KIND_HUDDLE_LIVENESS, P_GATED_KINDS, RESULT_GATED_KINDS, + KIND_DM_VISIBILITY, KIND_HUDDLE_LIVENESS, KIND_RELAY_BANNER, P_GATED_KINDS, RESULT_GATED_KINDS, SHARED_GATED_KINDS, }; use buzz_core::tenant::TenantContext; @@ -221,6 +221,30 @@ pub async fn handle_req( return; } + if filters_are_relay_banner_only(&filters) { + match crate::api::banners::active_banner_event_for_user(&state, &conn.tenant, &pubkey_bytes) + .await + { + Ok(Some(event)) => { + let stored = + buzz_core::StoredEvent::with_received_at(event, chrono::Utc::now(), None, true); + if filters_match(&filters, &stored) + && !conn.send(RelayMessage::event(&sub_id, &stored.event)) + { + return; + } + } + Ok(None) => {} + Err(error) => { + warn!(conn_id = %conn_id, sub_id = %sub_id, "Relay banner lookup failed: {error}"); + conn.send(RelayMessage::closed(&sub_id, "error: database error")); + return; + } + } + conn.send(RelayMessage::eose(&sub_id)); + return; + } + // Applied BEFORE the NIP-50 search branch so that an authenticated member // cannot use `{"search":"...","kinds":[30174]}` (or similar for p-gated // kinds) to harvest indexed-but-globally-stored sensitive events. Search @@ -1159,6 +1183,18 @@ fn filters_are_huddle_liveness_only(filters: &[Filter]) -> bool { }) } +fn filters_are_relay_banner_only(filters: &[Filter]) -> bool { + !filters.is_empty() + && filters.iter().all(|filter| { + filter.kinds.as_ref().is_some_and(|kinds| { + kinds.len() == 1 + && kinds + .iter() + .all(|kind| kind.as_u16() as u32 == KIND_RELAY_BANNER) + }) + }) +} + fn huddle_liveness_session_ids(filters: &[Filter]) -> Vec { let d_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::D); let mut session_ids = Vec::new(); diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 61aedf70be..eabfbb8084 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -73,6 +73,18 @@ pub fn build_router(state: Arc) -> Router { .route("/events", post(api::bridge::submit_event)) .route("/query", post(api::bridge::query_events)) .route("/count", post(api::bridge::count_events)) + .route( + api::banners::BANNER_ACTIVE_PATH, + get(api::banners::get_active_banner), + ) + .route( + api::banners::BANNER_VIEW_ROUTE, + post(api::banners::ack_banner_view), + ) + .route( + api::banners::BANNER_DISMISS_ROUTE, + post(api::banners::ack_banner_dismiss), + ) // Relay-owned third-party GIF metadata proxy (NIP-98 auth). .route(api::gifs::SEARCH_PATH, post(api::gifs::search)) .route(api::gifs::SHARE_PATH, post(api::gifs::share)) diff --git a/migrations/0046_relay_banners.sql b/migrations/0046_relay_banners.sql new file mode 100644 index 0000000000..2be963e9ab --- /dev/null +++ b/migrations/0046_relay_banners.sql @@ -0,0 +1,62 @@ +-- Operator-configured deployment banners. +-- Exactly one active banner exists deployment-wide. Scope is either all +-- communities (target_all_communities=true) or an explicit non-empty set in +-- relay_banner_communities. + +CREATE TABLE relay_banners ( + id BIGSERIAL PRIMARY KEY, + public_id UUID NOT NULL DEFAULT gen_random_uuid(), + severity TEXT NOT NULL CHECK (severity IN ('info', 'warning', 'urgent')), + message TEXT NOT NULL CHECK (char_length(message) BETWEEN 1 AND 2000), + max_displays INTEGER NOT NULL CHECK (max_displays >= 1), + target_all_communities BOOLEAN NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + disabled_at TIMESTAMPTZ, + created_by BYTEA NOT NULL CHECK (length(created_by) = 32), + updated_by BYTEA NOT NULL CHECK (length(updated_by) = 32), + disabled_by BYTEA CHECK (disabled_by IS NULL OR length(disabled_by) = 32) +); + +CREATE UNIQUE INDEX idx_relay_banners_public_id + ON relay_banners (public_id); + +CREATE UNIQUE INDEX idx_relay_banners_one_active + ON relay_banners ((true)) + WHERE disabled_at IS NULL; + +CREATE INDEX idx_relay_banners_updated_at + ON relay_banners (updated_at DESC, id DESC); + +CREATE TABLE relay_banner_communities ( + banner_id BIGINT NOT NULL REFERENCES relay_banners(id) ON DELETE CASCADE, + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, + PRIMARY KEY (banner_id, community_id) +); + +CREATE INDEX idx_relay_banner_communities_community + ON relay_banner_communities (community_id, banner_id); + +CREATE TABLE relay_banner_user_state ( + banner_id BIGINT NOT NULL REFERENCES relay_banners(id) ON DELETE CASCADE, + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, + pubkey BYTEA NOT NULL CHECK (length(pubkey) = 32), + display_count INTEGER NOT NULL DEFAULT 0 CHECK (display_count >= 0), + dismissed_at TIMESTAMPTZ, + first_viewed_at TIMESTAMPTZ, + last_viewed_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (banner_id, community_id, pubkey), + CHECK (first_viewed_at IS NULL OR last_viewed_at IS NOT NULL) +); + +CREATE INDEX idx_relay_banner_user_state_pubkey + ON relay_banner_user_state (community_id, pubkey, banner_id); + +INSERT INTO _operator_global_tables (table_name, reason) VALUES + ('relay_banners', 'deployment-global operator-configured banner; no community_id intentionally'), + ('relay_banner_communities', 'deployment-global banner targeting allowlist; community_id is target provenance only'), + ('relay_banner_user_state', 'deployment-global per-user banner display state; community_id scopes the targeted impression only'); + +SELECT attach_community_write_fence('relay_banner_communities'); +SELECT attach_community_write_fence('relay_banner_user_state'); diff --git a/schema/schema.sql b/schema/schema.sql index d7074f359d..6e4e375088 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -1700,6 +1700,69 @@ BEGIN END $$; + + +-- Operator-configured deployment banners. +-- Exactly one active banner exists deployment-wide. Scope is either all +-- communities (target_all_communities=true) or an explicit non-empty set in +-- relay_banner_communities. + +CREATE TABLE relay_banners ( + id BIGSERIAL PRIMARY KEY, + public_id UUID NOT NULL DEFAULT gen_random_uuid(), + severity TEXT NOT NULL CHECK (severity IN ('info', 'warning', 'urgent')), + message TEXT NOT NULL CHECK (char_length(message) BETWEEN 1 AND 2000), + max_displays INTEGER NOT NULL CHECK (max_displays >= 1), + target_all_communities BOOLEAN NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + disabled_at TIMESTAMPTZ, + created_by BYTEA NOT NULL CHECK (length(created_by) = 32), + updated_by BYTEA NOT NULL CHECK (length(updated_by) = 32), + disabled_by BYTEA CHECK (disabled_by IS NULL OR length(disabled_by) = 32) +); + +CREATE UNIQUE INDEX idx_relay_banners_public_id + ON relay_banners (public_id); + +CREATE UNIQUE INDEX idx_relay_banners_one_active + ON relay_banners ((true)) + WHERE disabled_at IS NULL; + +CREATE INDEX idx_relay_banners_updated_at + ON relay_banners (updated_at DESC, id DESC); + +CREATE TABLE relay_banner_communities ( + banner_id BIGINT NOT NULL REFERENCES relay_banners(id) ON DELETE CASCADE, + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, + PRIMARY KEY (banner_id, community_id) +); + +CREATE INDEX idx_relay_banner_communities_community + ON relay_banner_communities (community_id, banner_id); + +CREATE TABLE relay_banner_user_state ( + banner_id BIGINT NOT NULL REFERENCES relay_banners(id) ON DELETE CASCADE, + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, + pubkey BYTEA NOT NULL CHECK (length(pubkey) = 32), + display_count INTEGER NOT NULL DEFAULT 0 CHECK (display_count >= 0), + dismissed_at TIMESTAMPTZ, + first_viewed_at TIMESTAMPTZ, + last_viewed_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (banner_id, community_id, pubkey), + CHECK (first_viewed_at IS NULL OR last_viewed_at IS NOT NULL) +); + +CREATE INDEX idx_relay_banner_user_state_pubkey + ON relay_banner_user_state (community_id, pubkey, banner_id); + +INSERT INTO _operator_global_tables (table_name, reason) VALUES + ('relay_banners', 'deployment-global operator-configured banner; no community_id intentionally'), + ('relay_banner_communities', 'deployment-global banner targeting allowlist; community_id is target provenance only'), + ('relay_banner_user_state', 'deployment-global per-user banner display state; community_id scopes the targeted impression only'); + + -- Attach the universal fence to every existing table carrying community_id, -- including deployment-private sidecars whose community_id is provenance. DO $$ @@ -1749,6 +1812,8 @@ SELECT attach_community_write_fence('push_wake_outbox'); SELECT attach_community_write_fence('reactions'); SELECT attach_community_write_fence('relay_invites'); SELECT attach_community_write_fence('relay_members'); +SELECT attach_community_write_fence('relay_banner_communities'); +SELECT attach_community_write_fence('relay_banner_user_state'); SELECT attach_community_write_fence('scheduled_workflow_fires'); SELECT attach_community_write_fence('subscriptions'); SELECT attach_community_write_fence('thread_metadata'); @@ -1761,7 +1826,6 @@ SELECT attach_community_write_fence('workflows'); -- Deployment-level principals staffed via the admin API. Config-backed operators -- (RELAY_OPERATOR_PUBKEYS, RELAY_OWNER_PUBKEY owner-fallback) are NOT seeded here; -- they are authoritative in config and outrank any DB row. - CREATE TABLE relay_operators ( pubkey BYTEA NOT NULL PRIMARY KEY CHECK (length(pubkey) = 32), role TEXT NOT NULL CHECK (role IN ('operator', 'moderator')),