feat(relay): add configurable relay banners

Signed-off-by: coder 0 <d97ebdbb198c7237c94f84ea8bb8a73583ea067407eebd0062abbb3962527fb1@buzz.block.builderlab.xyz>
This commit is contained in:
coder 0
2026-09-16 18:58:50 -04:00
parent 01b6174a1c
commit 1ddbc0ef77
13 changed files with 1704 additions and 6 deletions

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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,

View File

@@ -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).

View File

@@ -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<Self> {
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<CommunityId>),
}
/// 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<u8>,
}
/// 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<CommunityId>,
/// Row creation timestamp.
pub created_at: DateTime<Utc>,
/// Row update timestamp.
pub updated_at: DateTime<Utc>,
/// Operator pubkey that created the row.
pub created_by: Vec<u8>,
/// Disable timestamp, if disabled.
pub disabled_at: Option<DateTime<Utc>>,
}
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<Option<i64>> {
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<bool> {
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<Vec<crate::CommunityRecord>> {
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<Vec<RelayBannerRecord>> {
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<Option<RelayBannerRecord>> {
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<Vec<RelayBannerRecord>> {
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<RelayBannerRecord> {
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<bool> {
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<Option<RelayBannerRecord>> {
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<RelayBannerViewOutcome> {
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::<bool, _>("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<RelayBannerDismissOutcome> {
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<RelayBannerRecord> {
let community_uuids: Vec<Uuid> = 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<u8> {
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());
}
}

View File

@@ -60,9 +60,13 @@ pub fn router(state: Arc<crate::state::AppState>) -> 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<Uuid>,
created_by: String,
created_at: DateTime<Utc>,
disabled_at: Option<DateTime<Utc>>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct BannersResponse {
active: Option<AdminBannerResponse>,
history: Vec<AdminBannerResponse>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct BannerQuery {
limit: Option<i64>,
}
#[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<Vec<Uuid>>,
}
impl From<buzz_db::RelayBannerRecord> 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<Arc<crate::state::AppState>>,
uri: Uri,
headers: HeaderMap,
) -> Result<Json<Vec<BannerCommunityResponse>>, 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<Arc<crate::state::AppState>>,
uri: Uri,
headers: HeaderMap,
Query(query): Query<BannerQuery>,
) -> Result<Json<BannersResponse>, 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<Arc<crate::state::AppState>>,
uri: Uri,
headers: HeaderMap,
body_bytes: Bytes,
) -> Result<Json<AdminBannerResponse>, 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<Arc<crate::state::AppState>>,
uri: Uri,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, 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.

View File

@@ -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<buzz_db::RelayBannerRecord> 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<nostr::Event, String> {
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<Option<nostr::Event>, 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<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<Option<BannerResponse>>, (axum::http::StatusCode, Json<Value>)> {
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<Arc<AppState>>,
Path(banner_id): Path<uuid::Uuid>,
headers: HeaderMap,
body: Bytes,
) -> Result<Json<BannerAckResponse>, (axum::http::StatusCode, Json<Value>)> {
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<Arc<AppState>>,
Path(banner_id): Path<uuid::Uuid>,
headers: HeaderMap,
body: Bytes,
) -> Result<Json<BannerAckResponse>, (axum::http::StatusCode, Json<Value>)> {
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<u64>),
(axum::http::StatusCode, Json<Value>),
> {
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<Value>) {
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<u64>,
) -> Result<(), (axum::http::StatusCode, Json<Value>)> {
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(|_| ())
}

View File

@@ -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<Value> = Vec::new();
let mut handled: std::collections::HashSet<usize> = 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`/

View File

@@ -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;

View File

@@ -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<uuid::Uuid> {
let d_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::D);
let mut session_ids = Vec::new();

View File

@@ -73,6 +73,18 @@ pub fn build_router(state: Arc<AppState>) -> 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))

View File

@@ -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');

View File

@@ -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')),