Files
buzz/crates/buzz-relay/src/api/bridge.rs

4275 lines
165 KiB
Rust

//! Nostr HTTP bridge — POST /events, /query, /count with NIP-98 auth.
//!
//! These endpoints provide HTTP access to the relay's Nostr protocol,
//! authenticated via NIP-98 signed events.
use std::sync::Arc;
use axum::{
extract::{Path, Query, RawQuery, State},
http::{HeaderMap, StatusCode},
response::Json,
};
use base64::Engine;
use serde_json::Value;
use buzz_auth::{LimitType, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS};
use buzz_core::TenantContext;
use crate::handlers::ingest::{IngestAuth, IngestError};
use crate::state::AppState;
use super::{api_error, internal_error, not_found};
pub(crate) async fn enforce_http_admission(
state: &AppState,
tenant: &TenantContext,
pubkey: &nostr::PublicKey,
) -> Result<(), (StatusCode, Json<Value>)> {
let limit = state.auth.config().rate_limits.human_api_calls_per_min;
match crate::admission::check_principal(
state.admission_rate_limiter.as_ref(),
tenant,
pubkey,
LimitType::ApiCalls,
60,
limit,
)
.await
{
Ok(()) => Ok(()),
Err(crate::admission::AdmissionError::Exceeded { reset_in_secs }) => {
metrics::counter!("buzz_admission_rejections_total", "transport" => "http", "reason" => "quota").increment(1);
Err(api_error(
StatusCode::TOO_MANY_REQUESTS,
&format!("rate-limited: quota exceeded; retry in {reset_in_secs}s"),
))
}
Err(crate::admission::AdmissionError::Unavailable) => {
metrics::counter!("buzz_admission_rejections_total", "transport" => "http", "reason" => "unavailable").increment(1);
Err(api_error(
StatusCode::SERVICE_UNAVAILABLE,
"rate-limited: shared admission unavailable",
))
}
}
}
/// Values retained from an already-verified bridge authentication event.
#[derive(Debug)]
pub(crate) struct VerifiedBridgeAuth {
pub(crate) pubkey: nostr::PublicKey,
pub(crate) event_id_bytes: [u8; 32],
pub(crate) signed_created_at: Option<u64>,
}
type BridgeAuthResult = Result<VerifiedBridgeAuth, (StatusCode, Json<Value>)>;
/// Verify bridge auth: NIP-98 (production) or X-Pubkey (dev mode).
///
/// Returns the authenticated public key, an event ID for replay detection, and
/// the verified signed auth timestamp. For X-Pubkey dev mode, the event ID is
/// a zero hash and the timestamp is absent.
pub(crate) fn verify_bridge_auth(
headers: &HeaderMap,
method: &str,
url: &str,
body: Option<&[u8]>,
require_auth_token: bool,
) -> BridgeAuthResult {
verify_bridge_auth_with_options(headers, method, url, body, require_auth_token, false)
}
pub(crate) fn verify_bridge_auth_with_options(
headers: &HeaderMap,
method: &str,
url: &str,
body: Option<&[u8]>,
require_auth_token: bool,
require_payload: bool,
) -> BridgeAuthResult {
// Try NIP-98 first (Authorization: Nostr <base64>)
if let Some(auth_str) = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Nostr "))
{
let event_json = {
use base64::engine::general_purpose::STANDARD as BASE64;
let bytes = BASE64
.decode(auth_str)
.map_err(|_| api_error(StatusCode::UNAUTHORIZED, "invalid base64 in Nostr auth"))?;
String::from_utf8(bytes)
.map_err(|_| api_error(StatusCode::UNAUTHORIZED, "invalid UTF-8 in Nostr auth"))?
};
let event: nostr::Event = serde_json::from_str(&event_json)
.map_err(|_| api_error(StatusCode::UNAUTHORIZED, "invalid NIP-98 event JSON"))?;
let event_id_bytes = event.id.to_bytes();
if require_payload
&& !event
.tags
.iter()
.any(|tag| tag.kind() == nostr::TagKind::Payload)
{
return Err(api_error(
StatusCode::UNAUTHORIZED,
"NIP-98: missing payload tag",
));
}
let pubkey = buzz_auth::verify_nip98_event(&event_json, url, method, body)
.map_err(|e| api_error(StatusCode::UNAUTHORIZED, &format!("NIP-98: {e}")))?;
return Ok(VerifiedBridgeAuth {
pubkey,
event_id_bytes,
signed_created_at: Some(event.created_at.as_secs()),
});
}
// Dev-mode fallback: X-Pubkey header (only when require_auth_token is false)
if !require_auth_token {
if let Some(hex_val) = headers.get("x-pubkey").and_then(|v| v.to_str().ok()) {
let pubkey = nostr::PublicKey::from_hex(hex_val)
.map_err(|_| api_error(StatusCode::UNAUTHORIZED, "invalid X-Pubkey hex"))?;
// Zero event ID — no replay detection needed for dev mode
return Ok(VerifiedBridgeAuth {
pubkey,
event_id_bytes: [0u8; 32],
signed_created_at: None,
});
}
}
Err(api_error(StatusCode::UNAUTHORIZED, "missing Nostr auth"))
}
/// Check NIP-98 replay and record the event ID atomically.
///
/// The correctness boundary is the shared, community-scoped Redis seen-set on
/// `AppState`, not process-local memory. Any Redis/guard error fails closed:
/// without the shared `SET NX EX` proof, a stateless worker cannot admit the
/// NIP-98 request safely.
pub(crate) async fn check_nip98_replay(
state: &AppState,
tenant: &TenantContext,
event_id_bytes: [u8; 32],
) -> Result<(), (StatusCode, Json<Value>)> {
check_nip98_replay_with_guard(state.nip98_replay.as_ref(), tenant, event_id_bytes).await
}
async fn check_nip98_replay_with_guard(
replay_guard: &dyn Nip98ReplayGuard,
tenant: &TenantContext,
event_id_bytes: [u8; 32],
) -> Result<(), (StatusCode, Json<Value>)> {
// Skip replay detection for dev-mode X-Pubkey auth (zero hash).
if event_id_bytes == [0u8; 32] {
return Ok(());
}
let event_id = nostr::EventId::from_byte_array(event_id_bytes);
match replay_guard
.try_mark(tenant, &event_id, DEFAULT_REPLAY_TTL_SECS)
.await
{
Ok(true) => Ok(()),
Ok(false) => Err(api_error(
StatusCode::UNAUTHORIZED,
"NIP-98: replay detected",
)),
Err(e) => {
tracing::warn!(
community = %tenant.community(),
error = %e,
"NIP-98 replay guard failed; rejecting request fail-closed"
);
Err(api_error(
StatusCode::UNAUTHORIZED,
"NIP-98: replay check unavailable",
))
}
}
}
/// Construct the NIP-98 `u`-tag expected URL for a request bound to `tenant`.
///
/// Conformance row 44 obligation: "NIP-98 `u` URL host must match
/// `req.community`." Host comes from the resolved [`TenantContext`] — the
/// same host the row-zero seam already bound from the request `Host` header —
/// and the scheme comes from the deployment's configured relay URL so
/// `ws`/`wss` deployments map to `http`/`https` consistently with how the
/// client signs the URL it is actually hitting.
///
/// Critically, this does NOT use `config_relay_url`'s host. `config.relay_url`
/// is one static string per deployment; under multi-tenant a relay serves many
/// hosts, only one of which would match. Using it as the URL match key would
/// (a) accept a NIP-98 event signed for community A's host when the request
/// arrives at community B's host (host-binding side door — verify_nip98 would
/// pass and the relay would proceed against the wrong tenant's auth context),
/// and (b) reject every legitimate request whose community host isn't the
/// single configured one. Substituting `tenant.host()` closes both directions.
pub(crate) fn nip98_expected_url(
config_relay_url: &str,
tenant: &TenantContext,
path: &str,
) -> String {
let scheme = if config_relay_url.trim_start().starts_with("wss://") {
"https"
} else {
"http"
};
format!("{scheme}://{}{path}", tenant.host())
}
/// Construct the NIP-42 expected `relay` URL for a connection bound to `tenant`.
///
/// NIP-42 (WebSocket AUTH) sibling of [`nip98_expected_url`]. Conformance row 44
/// obligation extends to the WS auth side: the AUTH event's `relay` tag must
/// match the per-tenant host the connection arrived on, not the deployment-wide
/// `config.relay_url`. Same hole the NIP-98 fix closed for HTTP — `config.relay_url`
/// is one static string per deployment, so verifying against it (a) admits an
/// AUTH event signed against community A's host on a connection bound to
/// community B (cross-host token reuse), and (b) rejects every legitimate AUTH
/// whose tenant host isn't the single configured one.
///
/// Scheme is `ws`/`wss` (not `http`/`https`) because the value being matched is
/// the client's connect URL embedded in the signed AUTH event; the helper
/// preserves the deployment's TLS posture from `config_relay_url`'s prefix so
/// `wss://` deployments stay `wss://` and `ws://` dev/test stays `ws://`.
/// Path is empty — clients put the bare WS origin (`ws://host[:port]`) in the
/// `relay` tag, matching how `EventBuilder::auth` accepts a [`nostr::RelayUrl`].
pub(crate) fn nip42_expected_relay_url(config_relay_url: &str, tenant: &TenantContext) -> String {
let scheme = if config_relay_url.trim_start().starts_with("wss://") {
"wss"
} else {
"ws"
};
format!("{scheme}://{}", tenant.host())
}
/// Extract a channel UUID from a single filter's `#h` tag.
fn extract_channel_from_filter(filter: &nostr::Filter) -> Option<uuid::Uuid> {
let h_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H);
filter.generic_tags.get(&h_tag).and_then(|vs| {
if vs.len() == 1 {
vs.iter().next()?.parse::<uuid::Uuid>().ok()
} else {
None
}
})
}
//
// The CLI injects extension fields (before_id, depth_limit, feed_types) into
// Nostr filter JSON. nostr::Filter silently drops unknown fields during
// deserialization, so we extract them from the raw JSON Value first.
const BRIDGE_FEED_MAX_LIMIT: i64 = 100;
const BRIDGE_THREAD_MAX_LIMIT: u32 = 500;
/// The `before_id` extension field, with "present but malformed" kept distinct
/// from "absent": NIP-CW's cursor grammar says a malformed value MUST reject
/// the request, never silently demote it to a half cursor or a head request.
enum BeforeId {
Absent,
Valid(Vec<u8>),
Malformed,
}
fn extract_before_id(raw: &Value) -> BeforeId {
let Some(value) = raw.get("before_id") else {
return BeforeId::Absent;
};
match value
.as_str()
.filter(|hex_str| hex_str.len() == 64)
.and_then(|hex_str| hex::decode(hex_str).ok())
{
Some(id) => BeforeId::Valid(id),
None => BeforeId::Malformed,
}
}
fn extract_buzz_channel(raw: &Value) -> Option<&str> {
raw.get("#buzz-channel")
.and_then(Value::as_array)
.filter(|values| values.len() == 1)
.and_then(|values| values.first())
.and_then(Value::as_str)
}
/// True when the raw filter opts into a bridge extension flag (`top_level`,
/// `include_summaries`, `include_aux`). Absent or non-boolean = false.
fn extension_flag(raw: &Value, key: &str) -> bool {
raw.get(key).and_then(Value::as_bool).unwrap_or(false)
}
fn extract_depth_limit(raw: &Value) -> Option<u32> {
raw.get("depth_limit")?
.as_u64()
.and_then(|n| u32::try_from(n).ok())
}
/// Extract a thread pagination cursor from the raw filter JSON.
///
/// The desktop pages `get_thread_replies` forward with a keyset cursor derived
/// transparently from the last reply it has already loaded — no server-issued
/// token. The cursor is a composite of that reply's `created_at` (Unix seconds,
/// field `thread_cursor`/`threadCursor`) and its hex event id (field
/// `thread_cursor_id`/`threadCursorId`). The event id is the tiebreak that lets
/// pagination cross replies sharing the same `created_at` second — without it,
/// a timestamp-only cursor silently drops every tied reply past the page limit
/// (the exact "missed messages" bug this work exists to fix).
///
/// Wire → DB encoding: 8-byte big-endian i64 seconds, followed by the raw
/// event-id bytes when present. `get_thread_replies` decodes this layout back
/// into its `(timestamp, event_id)` keyset. A bare timestamp (no id) is still
/// accepted and paginates on time alone (unsafe across same-second ties).
fn extract_thread_cursor(raw: &Value) -> Option<Vec<u8>> {
let secs = raw
.get("thread_cursor")
.or_else(|| raw.get("threadCursor"))?
.as_i64()?;
let mut bytes = secs.to_be_bytes().to_vec();
if let Some(id_hex) = raw
.get("thread_cursor_id")
.or_else(|| raw.get("threadCursorId"))
.and_then(Value::as_str)
{
if let Ok(id_bytes) = hex::decode(id_hex) {
bytes.extend_from_slice(&id_bytes);
}
}
Some(bytes)
}
fn extract_feed_types(raw: &Value) -> Option<Vec<String>> {
let arr = raw.get("feed_types")?.as_array()?;
let types: Vec<String> = arr
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect();
if types.is_empty() {
None
} else {
Some(types)
}
}
fn extract_search_mode(raw: &Value) -> buzz_search::SearchMode {
match raw
.get("search_mode")
.or_else(|| raw.get("searchMode"))
.and_then(Value::as_str)
{
Some("prefix") => buzz_search::SearchMode::Prefix,
_ => buzz_search::SearchMode::FullText,
}
}
fn extract_search_page(raw: &Value) -> u32 {
raw.get("page")
.or_else(|| raw.get("search_page"))
.or_else(|| raw.get("searchPage"))
.and_then(Value::as_u64)
.and_then(|value| u32::try_from(value).ok())
.filter(|value| *value > 0)
.unwrap_or(1)
}
/// Compute the SQL `OFFSET` for a raw `page` extension on a non-search general
/// query, or `None` if paging shouldn't apply.
///
/// `page` is 1-based: page 1 → offset 0 (no change), page N → `(N-1) * limit`.
/// Returns `None` when `page` is absent or ≤ 1 (so unrelated general queries
/// keep their default offset) and when `limit` is missing (can't size a page).
/// This mirrors the FTS path's `page`/`per_page` for the non-search directory
/// listing (empty-query kind:0), whose deterministic `created_at DESC, id ASC`
/// ordering in `query_events` makes offset paging stable.
fn extract_page_offset(raw: &Value, limit: Option<i64>) -> Option<i64> {
let page = raw
.get("page")
.and_then(Value::as_u64)
.and_then(|value| i64::try_from(value).ok())
.filter(|value| *value > 1)?;
let per_page = limit.filter(|l| *l > 0)?;
page.checked_sub(1)?.checked_mul(per_page)
}
/// Default and maximum row budget for a channel-window request. The budget
/// counts row events only; summary/bounds overlays and the aux closure never
/// consume it (docs/bridge-channel-window.md).
const BRIDGE_WINDOW_DEFAULT_LIMIT: u32 = 50;
const BRIDGE_WINDOW_MAX_LIMIT: u32 = 200;
/// Aux closure kinds: reactions, deletions (NIP-09 + NIP-29), edits.
const WINDOW_AUX_KINDS: [u32; 4] = [
buzz_core::kind::KIND_DELETION,
buzz_core::kind::KIND_REACTION,
buzz_core::kind::KIND_NIP29_DELETE_EVENT,
buzz_core::kind::KIND_STREAM_MESSAGE_EDIT,
];
/// Second-hop kinds: deletions targeting aux events (delete-of-a-reaction).
const WINDOW_AUX_DELETE_KINDS: [u32; 2] = [
buzz_core::kind::KIND_DELETION,
buzz_core::kind::KIND_NIP29_DELETE_EVENT,
];
/// Page size for one aux-closure hop. Matches the DB clamp
/// (`buzz_db::DEFAULT_MAX_PAGE_LIMIT`) so each page is one full query.
const AUX_PAGE_LIMIT: i64 = buzz_db::DEFAULT_MAX_PAGE_LIMIT;
/// Upper bound on pages drained per hop: 64k aux events referencing one page
/// of rows is far past any real thread; past it we log and stop rather than
/// loop forever against a pathological write pattern.
const AUX_MAX_PAGES: usize = 64;
fn build_aux_query(
community: buzz_core::CommunityId,
target_ids: Vec<String>,
kinds: &[u32],
) -> buzz_db::EventQuery {
let mut query = buzz_db::EventQuery::for_community(community);
query.kinds = Some(kinds.iter().map(|kind| *kind as i32).collect());
query.e_tags = Some(target_ids);
query
}
/// Where an aux hop reads from: the window path pins the request's proved
/// read session; the thread path takes the routed display-read fast path.
enum AuxReader<'a> {
Session(&'a mut buzz_db::ReadSession),
Routed(&'a buzz_db::Db, &'static str),
#[cfg(test)]
Fake(&'a mut (dyn FnMut(&buzz_db::EventQuery) -> Vec<buzz_core::StoredEvent> + Send)),
}
impl AuxReader<'_> {
async fn fetch(
&mut self,
query: &buzz_db::EventQuery,
) -> buzz_db::Result<Vec<buzz_core::StoredEvent>> {
match self {
AuxReader::Session(session) => session.query_events(query).await,
AuxReader::Routed(db, path) => db.query_events_routed(path, query).await,
#[cfg(test)]
AuxReader::Fake(fetch) => Ok(fetch(query)),
}
}
}
/// Drain every event matching `query`, walking the `(created_at, id)` keyset
/// cursor `query_events` already orders by until a short page. An aux hop
/// over a reaction-heavy page can exceed a single page clamp, and because
/// results are newest-first a one-shot query silently drops the *oldest*
/// edits and deletions — rendering original or deleted content, not merely
/// losing decoration.
async fn query_all_pages(
mut query: buzz_db::EventQuery,
page_limit: i64,
reader: &mut AuxReader<'_>,
) -> buzz_db::Result<Vec<buzz_core::StoredEvent>> {
query.limit = Some(page_limit);
let mut events = Vec::new();
for _ in 0..AUX_MAX_PAGES {
let page = reader.fetch(&query).await?;
let next = if page.len() as i64 >= page_limit {
page.last().map(|se| (se.event.created_at, se.event.id))
} else {
None
};
events.extend(page);
let Some((created_at, id)) = next else {
return Ok(events);
};
query.until = chrono::DateTime::from_timestamp(created_at.as_secs() as i64, 0);
query.before_id = Some(id.to_bytes().to_vec());
}
tracing::warn!(
pages = AUX_MAX_PAGES,
events = events.len(),
"aux closure hop exceeded page cap; returning truncated closure"
);
Ok(events)
}
/// Serve one `top_level: true` channel-window filter on the bridge `/query`
/// path (docs/bridge-channel-window.md). Appends, in order: row events, the
/// aux closure (`include_aux`), `39005` thread-summary overlays
/// (`include_summaries`), and exactly one `39006` window-bounds overlay.
///
/// Validation errors (missing `#h`, half a cursor) are deterministic client
/// mistakes and return `400`; an inaccessible channel is an access-scope skip
/// that still emits nothing, matching every other read path here.
async fn handle_channel_window_filter(
state: &AppState,
tenant: &buzz_core::TenantContext,
raw: &Value,
filter: &nostr::Filter,
accessible_channels: &[uuid::Uuid],
events: &mut Vec<Value>,
) -> Result<(), (StatusCode, Json<Value>)> {
use buzz_core::kind::{KIND_THREAD_SUMMARY, KIND_WINDOW_BOUNDS};
let Some(ch_id) = extract_channel_from_filter(filter) else {
return Err(api_error(
StatusCode::BAD_REQUEST,
"top_level requires exactly one #h channel",
));
};
if !accessible_channels.contains(&ch_id) {
return Ok(());
}
// Composite request cursor: `until` + `before_id`, both or neither. The
// window path has no timestamp-only fallback — that ambiguity is the
// dense-second dup/loss bug this surface exists to kill. A malformed
// `before_id` is likewise rejected outright (NIP-CW cursor grammar),
// never demoted to a half cursor or a head request.
let before_id = match extract_before_id(raw) {
BeforeId::Malformed => {
return Err(api_error(
StatusCode::BAD_REQUEST,
"top_level: before_id must be a 64-hex event id",
));
}
BeforeId::Valid(id) => Some(id),
BeforeId::Absent => None,
};
let cursor = match (filter.until, before_id) {
(Some(ts), Some(id)) => {
let ts = chrono::DateTime::from_timestamp(ts.as_secs() as i64, 0).ok_or_else(|| {
api_error(StatusCode::BAD_REQUEST, "top_level: until is out of range")
})?;
Some((ts, id))
}
(None, None) => None,
_ => {
return Err(api_error(
StatusCode::BAD_REQUEST,
"top_level cursor requires both until and before_id, or neither",
));
}
};
let limit = filter
.limit
.map(|l| (l as u32).min(BRIDGE_WINDOW_MAX_LIMIT))
.unwrap_or(BRIDGE_WINDOW_DEFAULT_LIMIT)
.max(1);
let kind_filter: Option<Vec<u32>> = filter
.kinds
.as_ref()
.map(|ks| ks.iter().map(|k| k.as_u16() as u32).collect());
let (window, mut session) = state
.db
.get_channel_window_with_session(
tenant.community(),
ch_id,
limit,
cursor.clone(),
kind_filter.as_deref(),
)
.await
.map_err(|e| internal_error(&format!("channel window error: {e}")))?;
// 1. Rows, in keyset order.
let mut row_ids_hex = Vec::with_capacity(window.rows.len());
for row in &window.rows {
row_ids_hex.push(row.stored_event.event.id.to_hex());
let v = serde_json::to_value(&row.stored_event.event)
.map_err(|e| internal_error(&format!("window row serialize: {e}")))?;
events.push(v);
}
// 2. Aux closure: reactions/deletions/edits targeting retained rows, plus
// deletions targeting those aux events (the transitive second hop).
// One round trip for the client instead of an #e fan-out. Runs in the
// SAME request transaction that served the window: when the page came
// from a proved replica session, the heartbeat observation anchored a
// REPEATABLE READ snapshot, so the aux hops see exactly the state the
// proof covered — another pooled session (or even another autocommit
// statement) could sit at a different replay position.
if extension_flag(raw, "include_aux") && !row_ids_hex.is_empty() {
let mut seen_aux: std::collections::HashSet<nostr::EventId> =
std::collections::HashSet::new();
let mut hop_ids = row_ids_hex.clone();
for hop_kinds in [&WINDOW_AUX_KINDS[..], &WINDOW_AUX_DELETE_KINDS[..]] {
let aux_query =
build_aux_query(tenant.community(), std::mem::take(&mut hop_ids), hop_kinds);
let aux_events = query_all_pages(
aux_query,
AUX_PAGE_LIMIT,
&mut AuxReader::Session(&mut session),
)
.await
.map_err(|e| internal_error(&format!("window aux error: {e}")))?;
for se in aux_events {
if !seen_aux.insert(se.event.id) {
continue;
}
// Deletions can be stored channel-less; access-check instead
// of channel-constraining so they aren't silently dropped.
if !event_in_accessible_channel(&se, accessible_channels) {
continue;
}
hop_ids.push(se.event.id.to_hex());
let v = serde_json::to_value(&se.event)
.map_err(|e| internal_error(&format!("window aux serialize: {e}")))?;
events.push(v);
}
if hop_ids.is_empty() {
break;
}
}
}
let sign_overlay = |kind: u32, tags: Vec<nostr::Tag>, content: String| {
nostr::EventBuilder::new(nostr::Kind::Custom(kind as u16), content)
.tags(tags)
.sign_with_keys(&state.relay_keypair)
.map_err(|e| internal_error(&format!("window overlay sign: {e}")))
};
let parse_tag = |parts: [&str; 2]| {
nostr::Tag::parse(parts).map_err(|e| internal_error(&format!("window overlay tag: {e}")))
};
let ch_hex = ch_id.to_string();
// 3. Thread-summary overlays: one relay-signed 39005 per row with replies.
if extension_flag(raw, "include_summaries") {
for row in &window.rows {
let Some(summary) = &row.thread_summary else {
continue;
};
let root_hex = row.stored_event.event.id.to_hex();
let content = serde_json::json!({
"reply_count": summary.reply_count,
"descendant_count": summary.descendant_count,
"last_reply_at": summary.last_reply_at.map(|t| t.timestamp()),
"participants": summary.participants.iter().map(hex::encode).collect::<Vec<_>>(),
});
let tags = vec![
parse_tag(["e", &root_hex])?,
parse_tag(["d", &root_hex])?,
parse_tag(["h", &ch_hex])?,
];
let overlay = sign_overlay(KIND_THREAD_SUMMARY, tags, content.to_string())?;
let v = serde_json::to_value(&overlay)
.map_err(|e| internal_error(&format!("window overlay serialize: {e}")))?;
events.push(v);
}
}
// 4. Window bounds: exactly one 39006 per window response — the only
// authority on exhaustion. `rows < limit` proves nothing on an
// exact-multiple final page.
let cursor_suffix = match &cursor {
Some((ts, id)) => format!("{}:{}", ts.timestamp(), hex::encode(id)),
None => "head".to_owned(),
};
let d_val = format!("{ch_hex}:{cursor_suffix}");
let content = serde_json::json!({
"has_more": window.has_more,
"next_cursor": window.next_cursor.as_ref().map(|(ts, id)| serde_json::json!({
"created_at": ts.timestamp(),
"id": hex::encode(id),
})),
});
let tags = vec![parse_tag(["d", &d_val])?, parse_tag(["h", &ch_hex])?];
let overlay = sign_overlay(KIND_WINDOW_BOUNDS, tags, content.to_string())?;
let v = serde_json::to_value(&overlay)
.map_err(|e| internal_error(&format!("window overlay serialize: {e}")))?;
events.push(v);
Ok(())
}
fn event_in_accessible_channel(se: &buzz_core::StoredEvent, accessible: &[uuid::Uuid]) -> bool {
match se.channel_id {
Some(ch_id) => accessible.contains(&ch_id),
None => true,
}
}
/// Hard cap on the `reason` field logged for a rejected `/events` request.
///
/// The reject message can embed event-controlled content (e.g. a submitted
/// channel's `visibility`/`channel_type` tag values, or a raw tag pubkey) —
/// attacker-controlled text that must never reach Datadog unbounded.
const REJECT_REASON_MAX_BYTES: usize = 256;
/// Truncate `s` to at most `max_bytes`, cutting at the nearest UTF-8 character
/// boundary so a multi-byte codepoint straddling the cutoff is never split.
/// Bounds attacker-controlled text before it enters a structured log line —
/// the line's size must stay bounded regardless of the triggering input size.
fn truncate_reason(s: &str, max_bytes: usize) -> &str {
if s.len() <= max_bytes {
return s;
}
let mut end = max_bytes;
while !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
/// Submit a signed Nostr event via HTTP bridge (NIP-98 auth).
pub async fn submit_event(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
// Row zero: bind this HTTP request to its community from the request host
// before any tenant-scoped write, identical to the WS door in `router.rs`.
// Unmapped host or lookup failure fails closed with a generic 404 — never a
// default tenant, never echoing the host.
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(
StatusCode::NOT_FOUND,
"relay: no community is configured for this host",
)
})?;
let url = nip98_expected_url(&state.config.relay_url, &tenant, "/events");
let VerifiedBridgeAuth {
pubkey,
event_id_bytes,
signed_created_at,
} = verify_bridge_auth(
&headers,
"POST",
&url,
Some(&body),
state.config.require_auth_token,
)?;
let pubkey_hex = pubkey.to_hex();
// Everything after auth — admission, replay, membership, parse, ingest —
// runs inside the helper. The thin wrapper here owns the single terminal
// attribution line so it fires for every outcome, including admission/
// replay/membership failures that previously returned before any log fired.
let outcome = submit_event_authed(
&state,
&tenant,
&headers,
&body,
pubkey,
event_id_bytes,
signed_created_at,
)
.await;
match &outcome {
SubmitOutcome::Ok { accepted, kind, .. } => {
tracing::info!(
pubkey = %pubkey_hex,
route = "/events",
status = 200u16,
accepted,
kind,
"HTTP bridge request"
);
}
SubmitOutcome::ParseFail {
category,
line,
column,
..
} => {
tracing::warn!(
pubkey = %pubkey_hex,
route = "/events",
status = 400u16,
accepted = false,
category,
line,
column,
"HTTP bridge request"
);
}
SubmitOutcome::Rejected { kind, reason, .. } => {
tracing::warn!(
pubkey = %pubkey_hex,
route = "/events",
status = 400u16,
accepted = false,
kind,
reason = %reason,
"HTTP bridge request"
);
}
SubmitOutcome::Err { status, .. } => {
tracing::warn!(
pubkey = %pubkey_hex,
route = "/events",
status = status.as_u16(),
accepted = false,
"HTTP bridge request"
);
}
}
outcome.into_response()
}
/// Log-context outcome for a single [`submit_event`] call.
///
/// Carries enough structured data for the terminal attribution log while also
/// holding the HTTP response so the thin wrapper can return it unchanged.
enum SubmitOutcome {
/// Ingest pipeline ran and returned a result (accepted or not).
Ok {
accepted: bool,
kind: u32,
response: Json<Value>,
},
/// JSON parse failure before ingest — log category/line/column, not msg.
ParseFail {
category: &'static str,
line: usize,
column: usize,
response: (StatusCode, Json<Value>),
},
/// IngestError::Rejected — log kind + truncated reason.
Rejected {
kind: u32,
reason: String,
response: (StatusCode, Json<Value>),
},
/// Any other error (admission, replay, membership, auth, internal) —
/// only the HTTP status is logged; the response body is returned as-is.
Err {
status: StatusCode,
response: (StatusCode, Json<Value>),
},
}
impl SubmitOutcome {
fn into_response(self) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
match self {
SubmitOutcome::Ok { response, .. } => Ok(response),
SubmitOutcome::ParseFail { response, .. } => Err(response),
SubmitOutcome::Rejected { response, .. } => Err(response),
SubmitOutcome::Err { response, .. } => Err(response),
}
}
}
/// Post-auth execution for [`submit_event`]: admission, replay, membership,
/// parse, and ingest. Returns a [`SubmitOutcome`] that carries both the log
/// fields and the HTTP response so the thin wrapper can emit exactly one
/// terminal attribution line covering every outcome.
async fn submit_event_authed(
state: &Arc<AppState>,
tenant: &TenantContext,
headers: &HeaderMap,
body: &[u8],
pubkey: nostr::PublicKey,
event_id_bytes: [u8; 32],
signed_auth_created_at: Option<u64>,
) -> SubmitOutcome {
// Admission and replay checks fire before body parse — a 429 or replay
// reject on a malformed body must still be attributed.
if let Err(e) = enforce_http_admission(state, tenant, &pubkey).await {
return SubmitOutcome::Err {
status: e.0,
response: e,
};
}
if let Err(e) = check_nip98_replay(state, tenant, event_id_bytes).await {
return SubmitOutcome::Err {
status: e.0,
response: e,
};
}
let pubkey_bytes = pubkey.to_bytes().to_vec();
let event: nostr::Event = match serde_json::from_slice(body) {
Ok(ev) => ev,
Err(e) => {
// Never log `e`'s Display string: serde_json embeds the offending
// input verbatim in its error message, so a malformed field of
// arbitrary size (the router allows 1 MiB bodies) would otherwise
// reflect attacker-controlled text into a log line at full size.
// `category`/`line`/`column` are bounded, structured, and still
// enough to tell "which parse failure" apart at a glance.
crate::handlers::ingest::reject_with_transport("http", "invalid");
return SubmitOutcome::ParseFail {
category: match e.classify() {
serde_json::error::Category::Io => "io",
serde_json::error::Category::Syntax => "syntax",
serde_json::error::Category::Data => "data",
serde_json::error::Category::Eof => "eof",
},
line: e.line(),
column: e.column(),
response: api_error(StatusCode::BAD_REQUEST, &format!("invalid event JSON: {e}")),
};
}
};
// Enforce relay membership (with NIP-OA fallback via x-auth-tag header).
let auth_tag = super::relay_members::extract_auth_tag_header(headers);
let nip_oa_owner = match super::relay_members::enforce_relay_membership(
state,
tenant.community(),
&pubkey_bytes,
auth_tag,
signed_auth_created_at,
)
.await
{
Ok(owner) => owner.or_else(|| {
if !state.config.require_relay_membership {
super::relay_members::extract_nip_oa_owner(
&pubkey_bytes,
auth_tag,
signed_auth_created_at,
)
} else {
None
}
}),
Err(e) => {
return SubmitOutcome::Err {
status: e.0,
response: e,
};
}
};
if let Some(owner) = nip_oa_owner {
super::relay_members::materialize_nip_oa_owner(state, tenant, &pubkey, &owner).await;
}
let kind_u32 = buzz_core::kind::event_kind_u32(&event);
let auth = IngestAuth::Http {
pubkey,
scopes: buzz_auth::Scope::all_known(), // Pure Nostr: full scopes, channel access via membership
auth_method: crate::handlers::ingest::HttpAuthMethod::Nip98,
};
match crate::handlers::ingest::ingest_event(state, tenant, event, auth).await {
Ok(result) => {
let response = Json(serde_json::json!({
"event_id": result.event_id,
"accepted": result.accepted,
"message": result.message,
}));
SubmitOutcome::Ok {
accepted: result.accepted,
kind: kind_u32,
response,
}
}
Err(IngestError::Rejected(msg)) => {
// `msg` can embed event-controlled content (e.g. a channel
// create's raw `visibility`/`channel_type` tag values, or a raw
// tag pubkey) — truncate before logging, but return the full msg
// in the HTTP response body (unchanged from prior behaviour).
let reason = truncate_reason(&msg, REJECT_REASON_MAX_BYTES).to_owned();
crate::handlers::ingest::reject_with_transport("http", "invalid");
SubmitOutcome::Rejected {
kind: kind_u32,
reason,
response: api_error(StatusCode::BAD_REQUEST, &msg),
}
}
Err(IngestError::AuthFailed(msg)) => {
crate::handlers::ingest::reject_with_transport("http", "auth");
let e = api_error(StatusCode::FORBIDDEN, &msg);
SubmitOutcome::Err {
status: e.0,
response: e,
}
}
Err(IngestError::Internal(msg)) => {
crate::handlers::ingest::reject_with_transport("http", "error");
let e = internal_error(&msg);
SubmitOutcome::Err {
status: e.0,
response: e,
}
}
}
}
/// Query events via HTTP bridge (NIP-98 auth). Returns JSON array of events.
///
/// Enforces channel access: results are filtered to channels the user can access.
pub async fn query_events(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
// Row zero: bind this HTTP request to its community from the request host
// before any tenant-scoped read, identical to the WS door in `router.rs`.
// An unmapped host or lookup failure fails closed with a generic 404 — never
// a default tenant, never echoing the host (so an unauthenticated caller
// cannot probe which communities exist on this deployment).
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(
StatusCode::NOT_FOUND,
"relay: no community is configured for this host",
)
})?;
let url = nip98_expected_url(&state.config.relay_url, &tenant, "/query");
let VerifiedBridgeAuth {
pubkey,
event_id_bytes,
signed_created_at,
} = verify_bridge_auth(
&headers,
"POST",
&url,
Some(&body),
state.config.require_auth_token,
)?;
let pubkey_hex = pubkey.to_hex();
// Admission, replay, membership, and filter execution all run inside the
// helper. The single terminal attribution line fires here from the Result
// so every outcome — including admission/replay/membership failures that
// previously returned before any log — is attributed.
let result = query_events_authed(
&state,
&tenant,
&headers,
&body,
pubkey,
event_id_bytes,
signed_created_at,
)
.await;
match &result {
Ok(Json(Value::Array(events))) => {
tracing::info!(
pubkey = %pubkey_hex,
route = "/query",
status = 200u16,
result_count = events.len(),
"HTTP bridge request"
);
}
Ok(_) => {
tracing::info!(pubkey = %pubkey_hex, route = "/query", status = 200u16, "HTTP bridge request");
}
Err((status, _)) => {
tracing::warn!(
pubkey = %pubkey_hex,
route = "/query",
status = status.as_u16(),
"HTTP bridge request"
);
}
}
result
}
/// Filter execution for [`query_events`], run once NIP-98 auth succeeds.
/// Handles admission, replay, membership, and all filter paths so the thin
/// wrapper above can emit exactly one terminal attribution line from the Result.
async fn query_events_authed(
state: &Arc<AppState>,
tenant: &TenantContext,
headers: &HeaderMap,
body: &[u8],
pubkey: nostr::PublicKey,
event_id_bytes: [u8; 32],
signed_auth_created_at: Option<u64>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
enforce_http_admission(state, tenant, &pubkey).await?;
check_nip98_replay(state, tenant, event_id_bytes).await?;
let pubkey_bytes = pubkey.to_bytes().to_vec();
let auth_tag = super::relay_members::extract_auth_tag_header(headers);
super::relay_members::enforce_relay_membership(
state,
tenant.community(),
&pubkey_bytes,
auth_tag,
signed_auth_created_at,
)
.await?;
// Two-pass parse: preserve raw JSON for custom extension fields (before_id,
// depth_limit, feed_types) that nostr::Filter silently drops.
let raw_filters: Vec<Value> = serde_json::from_slice(body)
.map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid filters: {e}")))?;
let filters: Vec<nostr::Filter> = raw_filters
.iter()
.map(|v| serde_json::from_value(v.clone()))
.collect::<Result<_, _>>()
.map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid filters: {e}")))?;
crate::handlers::req::extract_channel_ids_from_filters_limited(&filters)
.map_err(|()| api_error(StatusCode::BAD_REQUEST, "too many explicit channels"))?;
// P-gated kinds (gift wraps, member notifications, observer frames) require
// the caller's own pubkey in the #p tag — same enforcement as WS REQ handler.
let authed_pubkey_hex = pubkey.to_hex();
if !crate::handlers::req::p_gated_filters_authorized(&filters, &authed_pubkey_hex) {
return Err(api_error(
StatusCode::FORBIDDEN,
"restricted: p-gated kinds require #p tag matching your pubkey",
));
}
if !crate::handlers::req::engram_filters_authorized(&filters, &authed_pubkey_hex) {
return Err(api_error(
StatusCode::FORBIDDEN,
"restricted: agent-engram reads require authors=[self] or #p=[self]",
));
}
if !crate::handlers::req::author_only_filters_authorized(&filters, &authed_pubkey_hex) {
return Err(api_error(
StatusCode::FORBIDDEN,
"restricted: author-only kinds require authors=[self]",
));
}
// Get channels this user can access — same enforcement as WS REQ handler.
let mut accessible_channels = state
.get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes)
.await
.map_err(|e| internal_error(&format!("channel access lookup: {e}")))?;
repair_requested_channel_access(
state,
tenant,
&filters,
&pubkey_bytes,
&mut accessible_channels,
)
.await?;
if filters.iter().any(|f| f.search.is_some()) {
if has_mixed_search_filters(&filters) {
return Err(api_error(
StatusCode::BAD_REQUEST,
"mixed search and non-search filters not supported",
));
}
return handle_bridge_search(
state,
&raw_filters,
&filters,
&accessible_channels,
tenant,
&authed_pubkey_hex,
&pubkey_bytes,
)
.await;
}
if let Some(presence_result) =
synthesize_presence(&state.pubsub, &state.relay_keypair, tenant, &filters).await
{
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();
// Channel-window filters (`top_level: true`) — the GUI read-model surface.
// Dispatched first: a window filter is never a feed/thread/catchall query.
for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() {
if !extension_flag(raw, "top_level") {
continue;
}
handle_channel_window_filter(
state,
tenant,
raw,
filter,
&accessible_channels,
&mut events,
)
.await?;
handled.insert(idx);
}
for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() {
if handled.contains(&idx) {
continue;
}
let feed_types = match extract_feed_types(raw) {
Some(t) => t,
None => continue,
};
let limit = filter
.limit
.map(|l| (l as i64).min(BRIDGE_FEED_MAX_LIMIT))
.unwrap_or(20);
let since = filter
.since
.and_then(|s| chrono::DateTime::from_timestamp(s.as_secs() as i64, 0));
let mut seen_types = std::collections::HashSet::new();
let mut seen = std::collections::HashSet::new();
let mut feed_count = 0i64;
for feed_type in &feed_types {
let canonical = if feed_type == "agent_activity" {
"activity"
} else {
feed_type.as_str()
};
if !seen_types.insert(canonical) {
continue;
}
if feed_count >= limit {
break;
}
let remaining = limit - feed_count;
let type_events = match canonical {
"mentions" => state
.db
.query_feed_mentions_routed(
"bridge_feed",
tenant.community(),
&pubkey_bytes,
&accessible_channels,
since,
remaining,
)
.await
.map_err(|e| internal_error(&format!("feed mentions error: {e}")))?,
"needs_action" => state
.db
.query_feed_needs_action_routed(
"bridge_feed",
tenant.community(),
&pubkey_bytes,
&accessible_channels,
since,
remaining,
)
.await
.map_err(|e| internal_error(&format!("feed needs_action error: {e}")))?,
"activity" => state
.db
.query_feed_activity_routed(
"bridge_feed",
tenant.community(),
&accessible_channels,
since,
remaining,
)
.await
.map_err(|e| internal_error(&format!("feed activity error: {e}")))?,
_ => continue,
};
for se in type_events {
if !seen.insert(se.event.id) {
continue;
}
if !event_in_accessible_channel(&se, &accessible_channels) {
continue;
}
// Defense-in-depth: never deliver a result-gated event (e.g. kind:44200
// or kind:30622) to a non-owner via the feed path, even though feed SQL
// kind allowlists already exclude these kinds.
if !buzz_core::filter::reader_authorized_for_event(&se.event, &authed_pubkey_hex) {
continue;
}
if let Ok(v) = serde_json::to_value(&se.event) {
events.push(v);
feed_count += 1;
}
}
}
handled.insert(idx);
}
let e_tag_key = nostr::SingleLetterTag::lowercase(nostr::Alphabet::E);
for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() {
if handled.contains(&idx) {
continue;
}
let depth = match extract_depth_limit(raw) {
Some(d) => d,
None => continue,
};
let e_values = match filter.generic_tags.get(&e_tag_key) {
Some(vs) if vs.len() == 1 => vs,
_ => continue,
};
let root_hex = match e_values.iter().next() {
Some(h) => h,
None => continue,
};
let root_bytes = match hex::decode(root_hex) {
Ok(b) if b.len() == 32 => b,
_ => continue,
};
if let Some(ch_id) = extract_channel_from_filter(filter) {
if !accessible_channels.contains(&ch_id) {
handled.insert(idx);
continue;
}
}
let limit = filter
.limit
.unwrap_or(100)
.min(BRIDGE_THREAD_MAX_LIMIT as usize) as u32;
let thread_cursor = extract_thread_cursor(raw);
let thread_replies = state
.db
.get_thread_replies(
tenant.community(),
&root_bytes,
Some(depth),
limit,
thread_cursor.as_deref(),
)
.await
.map_err(|e| internal_error(&format!("thread query error: {e}")))?;
let mut thread_row_ids = Vec::with_capacity(thread_replies.len() + 1);
thread_row_ids.push(root_hex.to_string());
for reply in thread_replies {
let se = reply.stored_event;
if !event_in_accessible_channel(&se, &accessible_channels) {
continue;
}
// Defense-in-depth: never deliver a result-gated event (e.g. kind:44200
// or kind:30622) to a non-owner via the thread path, even though
// requires_h_channel_scope already excludes these kinds from thread metadata.
if !buzz_core::filter::reader_authorized_for_event(&se.event, &authed_pubkey_hex) {
continue;
}
thread_row_ids.push(se.event.id.to_hex());
if let Ok(v) = serde_json::to_value(&se.event) {
events.push(v);
}
}
if extension_flag(raw, "include_aux") && !thread_row_ids.is_empty() {
let mut seen_aux = std::collections::HashSet::new();
let mut hop_ids = thread_row_ids;
for hop_kinds in [&WINDOW_AUX_KINDS[..], &WINDOW_AUX_DELETE_KINDS[..]] {
let aux_query =
build_aux_query(tenant.community(), std::mem::take(&mut hop_ids), hop_kinds);
let aux_events = query_all_pages(
aux_query,
AUX_PAGE_LIMIT,
&mut AuxReader::Routed(&state.db, "bridge_thread_aux"),
)
.await
.map_err(|e| internal_error(&format!("thread aux query error: {e}")))?;
for se in aux_events {
if !seen_aux.insert(se.event.id)
|| !event_in_accessible_channel(&se, &accessible_channels)
|| !buzz_core::filter::reader_authorized_for_event(
&se.event,
&authed_pubkey_hex,
)
{
continue;
}
hop_ids.push(se.event.id.to_hex());
if let Ok(value) = serde_json::to_value(&se.event) {
events.push(value);
}
}
if hop_ids.is_empty() {
break;
}
}
}
handled.insert(idx);
}
// Phase 1 — pure construction + validation, in filter order. Access-scope
// skips and the `before_id` BAD_REQUEST are decided here, before any DB
// work is issued (validation errors are deterministic client mistakes, so
// surfacing them ahead of transient DB errors is strictly more predictable).
let mut catchall_queries: Vec<(usize, buzz_db::EventQuery)> = Vec::new();
for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() {
if handled.contains(&idx) {
continue;
}
if let Some(ch_id) = extract_channel_from_filter(filter) {
if !accessible_channels.contains(&ch_id) {
continue;
}
}
let mut query = crate::handlers::req::build_event_query_from_filter(
filter,
&pubkey_bytes,
state,
tenant.community(),
)
.await;
crate::handlers::req::apply_channel_scope_to_query(
&mut query,
filter,
extract_channel_from_filter(filter),
&accessible_channels,
);
if let Some(channel) = extract_buzz_channel(raw) {
query.custom_tag = Some(("buzz-channel".into(), channel.into()));
}
// Shared-gated visibility pushdown: must mirror WS REQ so that a page of
// newer private events does not starve older shared ones off the page.
if crate::handlers::req::filter_can_match_shared_gated_kinds(filter) {
query.shared_gated_reader = Some(pubkey_bytes.clone());
}
match extract_before_id(raw) {
BeforeId::Malformed => {
return Err(api_error(
StatusCode::BAD_REQUEST,
"before_id must be a 64-char hex event id",
));
}
BeforeId::Valid(bid) => {
if query.until.is_none() {
return Err(api_error(
StatusCode::BAD_REQUEST,
"before_id requires until to be set",
));
}
query.before_id = Some(bid);
}
BeforeId::Absent => {}
}
// Honor `page` on non-search general queries so offset paging works for
// the empty-query people directory (kind:0 listing). The FTS path
// (`handle_bridge_search`) has its own `page`/`per_page`; a filter with
// no `search` field lands here instead, where paging would otherwise be
// dropped and the directory would terminate at its first page. Deterministic
// ordering in `query_events` (`created_at DESC, id ASC`) makes offset paging
// stable. `page` defaults to 1 → offset 0, so unrelated general queries are
// unaffected.
if let Some(offset) = extract_page_offset(raw, query.limit) {
query.offset = Some(offset);
}
catchall_queries.push((idx, query));
}
// Phase 2 — DB reads, bounded-concurrent, order-preserving (`buffered`).
// Phase 3 consumes results in original filter order, so response ordering
// and error semantics match the previous serial loop.
use futures_util::stream::{self, StreamExt};
let db = state.db.clone();
let mut catchall_results = stream::iter(catchall_queries.into_iter().map(|(idx, query)| {
let db = db.clone();
async move { (idx, db.query_events_routed("bridge_query", &query).await) }
}))
.buffered(crate::handlers::req::FILTER_QUERY_CONCURRENCY);
// Phase 3 — post-processing, strictly in filter order.
while let Some((idx, filter_events)) = catchall_results.next().await {
let filter = &filters[idx];
match filter_events {
Ok(stored_events) => {
for se in stored_events {
if !event_in_accessible_channel(&se, &accessible_channels) {
continue;
}
if !buzz_core::filter::filters_match(std::slice::from_ref(filter), &se) {
continue;
}
// Result-level read auth: never hand a viewer-private snapshot
// (kind:30622) to anyone but its owner, even via kindless `ids`.
// Also enforces author-only kinds (30300/30350) and the persona
// shared-gate (kind:30175 without ["shared","true"]). Single call
// covers all three gated event classes.
if !crate::handlers::req::event_visible_to_reader(&se.event, &pubkey_bytes) {
continue;
}
if let Ok(v) = serde_json::to_value(&se.event) {
events.push(v);
}
}
}
Err(e) => {
return Err(internal_error(&format!("query error: {e}")));
}
}
}
Ok(Json(Value::Array(events)))
}
async fn repair_requested_channel_access(
state: &AppState,
tenant: &TenantContext,
filters: &[nostr::Filter],
pubkey_bytes: &[u8],
accessible_channels: &mut Vec<uuid::Uuid>,
) -> Result<(), (StatusCode, Json<Value>)> {
for filter in filters {
let Some(requested) =
crate::handlers::req::extract_channel_ids_from_filters(std::slice::from_ref(filter))
else {
continue;
};
for channel_id in requested {
if accessible_channels.contains(&channel_id) {
continue;
}
let is_member = state
.db
.is_member(tenant.community(), channel_id, pubkey_bytes)
.await
.map_err(|e| internal_error(&format!("channel membership confirmation: {e}")))?;
crate::handlers::req::resolve_request_local_access(
accessible_channels,
channel_id,
true,
Some(is_member),
);
}
}
Ok(())
}
/// Count events via HTTP bridge (NIP-98 auth). Returns `{"count": N}`.
///
/// Enforces channel access: only counts events in channels the user can access.
/// For filters without a `#h` tag, falls back to per-event counting with access checks.
pub async fn count_events(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
// Row zero: bind this HTTP request to its community from the request host
// before any tenant-scoped read, identical to the WS door in `router.rs`
// and `query_events`/`submit_event` above. Fail-closed; never a default
// tenant, never echoing the host.
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(
StatusCode::NOT_FOUND,
"relay: no community is configured for this host",
)
})?;
let url = nip98_expected_url(&state.config.relay_url, &tenant, "/count");
let VerifiedBridgeAuth {
pubkey,
event_id_bytes,
signed_created_at,
} = verify_bridge_auth(
&headers,
"POST",
&url,
Some(&body),
state.config.require_auth_token,
)?;
let pubkey_hex = pubkey.to_hex();
// Admission, replay, membership, and count execution all run inside the
// helper. The single terminal attribution line fires here from the Result
// so every outcome — including admission/replay/membership failures that
// previously returned before any log — is attributed.
let result = count_events_authed(
&state,
&tenant,
&headers,
&body,
pubkey,
event_id_bytes,
signed_created_at,
)
.await;
match &result {
Ok(Json(value)) => {
let count = value.get("count").and_then(Value::as_u64);
tracing::info!(
pubkey = %pubkey_hex,
route = "/count",
status = 200u16,
result_count = count,
"HTTP bridge request"
);
}
Err((status, _)) => {
tracing::warn!(
pubkey = %pubkey_hex,
route = "/count",
status = status.as_u16(),
"HTTP bridge request"
);
}
}
result
}
/// Filter execution for [`count_events`], run once NIP-98 auth succeeds.
/// Handles admission, replay, membership, and count execution so the thin
/// wrapper above can emit exactly one terminal attribution line from the Result.
async fn count_events_authed(
state: &Arc<AppState>,
tenant: &TenantContext,
headers: &HeaderMap,
body: &[u8],
pubkey: nostr::PublicKey,
event_id_bytes: [u8; 32],
signed_auth_created_at: Option<u64>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
enforce_http_admission(state, tenant, &pubkey).await?;
check_nip98_replay(state, tenant, event_id_bytes).await?;
let pubkey_bytes = pubkey.to_bytes().to_vec();
let auth_tag = super::relay_members::extract_auth_tag_header(headers);
super::relay_members::enforce_relay_membership(
state,
tenant.community(),
&pubkey_bytes,
auth_tag,
signed_auth_created_at,
)
.await?;
let filters: Vec<nostr::Filter> = serde_json::from_slice(body)
.map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid filters: {e}")))?;
crate::handlers::req::extract_channel_ids_from_filters_limited(&filters)
.map_err(|()| api_error(StatusCode::BAD_REQUEST, "too many explicit channels"))?;
// P-gated kinds enforcement — same as WS REQ and /query.
let authed_pubkey_hex = pubkey.to_hex();
if !crate::handlers::req::p_gated_filters_authorized(&filters, &authed_pubkey_hex) {
return Err(api_error(
StatusCode::FORBIDDEN,
"restricted: p-gated kinds require #p tag matching your pubkey",
));
}
if !crate::handlers::req::engram_filters_authorized(&filters, &authed_pubkey_hex) {
return Err(api_error(
StatusCode::FORBIDDEN,
"restricted: agent-engram reads require authors=[self] or #p=[self]",
));
}
if !crate::handlers::req::author_only_filters_authorized(&filters, &authed_pubkey_hex) {
return Err(api_error(
StatusCode::FORBIDDEN,
"restricted: author-only kinds require authors=[self]",
));
}
// Get channels this user can access.
let mut accessible_channels = state
.get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes)
.await
.map_err(|e| internal_error(&format!("channel access lookup: {e}")))?;
repair_requested_channel_access(
state,
tenant,
&filters,
&pubkey_bytes,
&mut accessible_channels,
)
.await?;
let mut total: u64 = 0;
for filter in &filters {
let needs_author_only_filtering =
crate::handlers::req::filter_can_match_author_only_kinds(filter);
// Same result-gated guard as the WS COUNT handler: force the per-event
// fallback for filters that can match 44200 or 30622 unless #p=[self]
// is safely pushed down (existence leak otherwise).
let needs_result_gated_filtering =
crate::handlers::req::filter_can_match_result_gated_kinds(filter)
&& !crate::handlers::req::result_gated_count_safe_for_pushdown(
filter,
&authed_pubkey_hex,
);
// Force per-event fallback for filters that can match a shared-gated
// kind — the fast SQL count_events() path has no per-event gate and
// would over-count foreign unshared events (existence leak).
let needs_shared_gate_filtering =
crate::handlers::req::filter_can_match_shared_gated_kinds(filter);
// If filter targets a specific channel, verify access.
if crate::handlers::req::extract_channel_ids_from_filters(std::slice::from_ref(filter))
.is_some()
{
let ch_id = extract_channel_from_filter(filter);
let requested = crate::handlers::req::extract_channel_ids_from_filters(
std::slice::from_ref(filter),
)
.unwrap_or_default();
if !requested
.iter()
.any(|channel_id| accessible_channels.contains(channel_id))
{
continue;
}
// Channel is accessible — count with pushability check.
let mut query = crate::handlers::req::build_event_query_from_filter(
filter,
&pubkey_bytes,
state,
tenant.community(),
)
.await;
crate::handlers::req::apply_channel_scope_to_query(
&mut query,
filter,
ch_id,
&accessible_channels,
);
// Shared-gated visibility pushdown: same as REQ and /query paths, so
// the fallback's query_events call doesn't over-fetch private rows.
if needs_shared_gate_filtering {
query.shared_gated_reader = Some(pubkey_bytes.clone());
}
let author_is_self = filter.authors.as_ref().is_some_and(|authors| {
!authors.is_empty()
&& authors
.iter()
.all(|a| a.to_hex().eq_ignore_ascii_case(&authed_pubkey_hex))
});
if crate::handlers::req::filter_fully_pushable(filter)
&& (!needs_author_only_filtering || author_is_self)
&& !needs_result_gated_filtering
&& !needs_shared_gate_filtering
{
match state.db.count_events_routed("bridge_count", &query).await {
Ok(n) => total += n as u64,
Err(e) => {
return Err(internal_error(&format!("count error: {e}")));
}
}
} else {
// Fallback: query + post-filter for non-pushable constraints.
let mut q = query;
crate::handlers::req::apply_count_fallback_limit(&mut q);
match state
.db
.query_events_routed_bounded("bridge_count_fallback", &q)
.await
{
Ok(stored_events) => {
if crate::handlers::req::count_fallback_exceeded(stored_events.len()) {
metrics::counter!("buzz_count_fallback_rejections_total").increment(1);
return Err(api_error(
StatusCode::BAD_REQUEST,
"count filter requires narrower constraints",
));
}
for se in stored_events {
if !buzz_core::filter::filters_match(std::slice::from_ref(filter), &se)
{
continue;
}
if !crate::handlers::req::event_visible_to_reader(
&se.event,
&pubkey_bytes,
) {
continue;
}
total += 1;
}
}
Err(e) => {
return Err(internal_error(&format!("count error: {e}")));
}
}
}
} else {
// No channel filter — use SQL-level channel_ids pushdown to count
// only events in accessible channels (+ global events).
let mut query = crate::handlers::req::build_event_query_from_filter(
filter,
&pubkey_bytes,
state,
tenant.community(),
)
.await;
query.channel_ids = Some(accessible_channels.to_vec());
// Shared-gated visibility pushdown: pre-filter before ORDER/LIMIT on
// the fallback query_events path.
if needs_shared_gate_filtering {
query.shared_gated_reader = Some(pubkey_bytes.clone());
}
let author_is_self = filter.authors.as_ref().is_some_and(|authors| {
!authors.is_empty()
&& authors
.iter()
.all(|a| a.to_hex().eq_ignore_ascii_case(&authed_pubkey_hex))
});
if crate::handlers::req::filter_fully_pushable(filter)
&& (!needs_author_only_filtering || author_is_self)
&& !needs_result_gated_filtering
&& !needs_shared_gate_filtering
{
query.limit = None;
match state.db.count_events_routed("bridge_count", &query).await {
Ok(n) => total += n as u64,
Err(e) => {
return Err(internal_error(&format!("count error: {e}")));
}
}
} else {
// Fallback: query a bounded candidate set + post-filter.
crate::handlers::req::apply_count_fallback_limit(&mut query);
match state
.db
.query_events_routed_bounded("bridge_count_fallback", &query)
.await
{
Ok(stored_events) => {
if crate::handlers::req::count_fallback_exceeded(stored_events.len()) {
metrics::counter!("buzz_count_fallback_rejections_total").increment(1);
return Err(api_error(
StatusCode::BAD_REQUEST,
"count filter requires narrower constraints",
));
}
for se in stored_events {
if !buzz_core::filter::filters_match(std::slice::from_ref(filter), &se)
{
continue;
}
if !crate::handlers::req::event_visible_to_reader(
&se.event,
&pubkey_bytes,
) {
continue;
}
total += 1;
}
}
Err(e) => {
return Err(internal_error(&format!("count error: {e}")));
}
}
}
}
}
Ok(Json(serde_json::json!({ "count": total })))
}
fn has_mixed_search_filters(filters: &[nostr::Filter]) -> bool {
filters.iter().any(|f| f.search.is_some()) && filters.iter().any(|f| f.search.is_none())
}
/// Decide whether a search hit should be returned to the caller.
///
/// Mirrors the WS NIP-50 path's post-filter step in `handlers/req.rs`:
/// the FTS backend receives only the kind/authors/time pushdown, so any other filter
/// constraint (`#p`, `#h`, `#e`, `#d`, `ids`, …) must be enforced here against
/// the full stored event. Without this, an authorized engram search such as
/// `{"kinds":[30174],"#p":[self]}` would leak text-matching envelopes whose
/// `#p` belongs to a different owner — the NIP-AE read gate at the filter
/// layer would be bypassed for `/query`.
///
/// `accessible_channels` is the caller's channel scope; channel-scoped hits
/// outside that set are rejected regardless of NIP-01 match.
fn search_hit_accepted(
filter: &nostr::Filter,
stored: &buzz_core::StoredEvent,
accessible_channels: &[uuid::Uuid],
reader_pubkey_hex: &str,
) -> bool {
if !buzz_core::filter::filters_match(std::slice::from_ref(filter), stored) {
return false;
}
if let Some(ch_id) = stored.channel_id {
if !accessible_channels.contains(&ch_id) {
return false;
}
}
if !buzz_core::filter::reader_authorized_for_event(&stored.event, reader_pubkey_hex) {
return false;
}
true
}
/// Handle search filters by routing to Postgres FTS, then fetching full events
/// from DB. Supports a bridge-only `page` extension over the FTS result set.
async fn handle_bridge_search(
state: &AppState,
raw_filters: &[Value],
filters: &[nostr::Filter],
accessible_channels: &[uuid::Uuid],
tenant: &buzz_core::tenant::TenantContext,
reader_pubkey_hex: &str,
pubkey_bytes: &[u8],
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
// Bridge always includes global (channel-less) events — same as WS with
// full scopes. `None` means no accessible channels and no global access →
// empty result set (the caller short-circuits exactly as the WS door EOSEs).
let channel_scope = match crate::handlers::req::build_search_channel_scope_filter(
accessible_channels,
true, // include_global
) {
Some(scope) => scope,
None => return Ok(Json(Value::Array(Vec::new()))),
};
let mut events: Vec<Value> = Vec::new();
let mut seen_ids: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
for (raw, filter) in raw_filters.iter().zip(filters) {
let search_mode = extract_search_mode(raw);
let search_page = extract_search_page(raw);
let search_text = match &filter.search {
Some(s) if !s.is_empty() => s.clone(),
_ => continue,
};
let limit = filter.limit.unwrap_or(100).min(500) as u32;
if limit == 0 {
continue;
}
// Scope by channel — push the #h tag (intersected with accessible
// channels) if present, else the community-wide scope.
let h_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H);
let filter_channel_scope =
if let Some(vs) = filter.generic_tags.get(&h_tag).filter(|vs| !vs.is_empty()) {
let valid: Vec<uuid::Uuid> = vs
.iter()
.filter_map(|v| v.parse::<uuid::Uuid>().ok())
.filter(|id| accessible_channels.contains(id))
.collect();
if valid.is_empty() {
continue; // All #h values inaccessible — skip filter.
}
buzz_search::ChannelScope::Channels(valid)
} else {
channel_scope.clone()
};
let kinds = filter.kinds.as_ref().and_then(|ks| {
if ks.is_empty() {
None
} else {
Some(ks.iter().map(|k| k.as_u16() as i32).collect::<Vec<_>>())
}
});
let authors = filter.authors.as_ref().and_then(|au| {
if au.is_empty() {
None
} else {
Some(au.iter().map(|a| a.to_bytes().to_vec()).collect::<Vec<_>>())
}
});
let since = filter.since.map(|s| s.as_secs() as i64);
let until = filter.until.map(|u| u.as_secs() as i64);
let search_query = buzz_search::SearchQuery {
community: tenant.community(),
q: search_text,
channel_scope: filter_channel_scope,
kinds,
authors,
since,
until,
page: search_page,
per_page: limit,
mode: search_mode,
};
let search_result = state
.search
.search(&search_query)
.await
.map_err(|e| internal_error(&format!("search error: {e}")))?;
// Fetch full events from DB by ID. Hit ids are already raw 32-byte
// arrays from the FTS layer — no hex decode.
let hit_ids: Vec<[u8; 32]> = search_result.hits.into_iter().map(|h| h.event_id).collect();
if hit_ids.is_empty() {
continue;
}
let id_refs: Vec<&[u8]> = hit_ids.iter().map(|b| b.as_slice()).collect();
let stored_events = state
.db
.get_events_by_ids_routed("bridge_search_hydrate", tenant.community(), &id_refs)
.await
.map_err(|e| internal_error(&format!("search fetch error: {e}")))?;
// Build lookup map to preserve FTS relevance ordering.
let event_map: std::collections::HashMap<[u8; 32], &buzz_core::StoredEvent> = stored_events
.iter()
.map(|ev| (ev.event.id.to_bytes(), ev))
.collect();
for id_array in &hit_ids {
let stored = match event_map.get(id_array) {
Some(ev) => ev,
None => continue,
};
if !search_hit_accepted(filter, stored, accessible_channels, reader_pubkey_hex) {
continue;
}
// Defense-in-depth: apply the full per-event visibility gate, which
// covers author-only kinds, the persona shared-gate (kind:30175), and
// result-gated kinds. Kind:30175 is not in the FTS positive allowlist
// today (migration 8 indexes only 0,9,40002,45001,45003), so this
// branch cannot currently return unshared persona content — but the
// check here ensures that a future FTS allowlist change cannot silently
// reopen the bypass.
if !crate::handlers::req::event_visible_to_reader(&stored.event, pubkey_bytes) {
continue;
}
// Dedup across filters.
if !seen_ids.insert(*id_array) {
continue;
}
if let Ok(v) = serde_json::to_value(&stored.event) {
events.push(v);
}
}
}
Ok(Json(Value::Array(events)))
}
/// Query parameters for the webhook trigger endpoint.
#[derive(serde::Deserialize)]
pub struct WebhookQuery {
/// Webhook secret for authentication. Prefer the `X-Webhook-Secret` header instead.
pub secret: Option<String>,
}
/// Webhook trigger endpoint. No user auth — the webhook secret authenticates the caller.
///
/// Prefers `X-Webhook-Secret` header over `?secret=` query param (headers aren't logged
/// by most proxies). Returns 202 Accepted; execution is async.
pub async fn workflow_webhook(
State(state): State<Arc<AppState>>,
Path(id_str): Path<String>,
Query(query): Query<WebhookQuery>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<(StatusCode, Json<Value>), (StatusCode, Json<Value>)> {
let id = uuid::Uuid::parse_str(&id_str)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid workflow UUID"))?;
// Row zero: bind this webhook to its community from the request host before
// any tenant-scoped lookup or write. The host — not the workflow row —
// determines the tenant: a request for community A's host may only reach
// community A's workflows, even when the same workflow UUID also exists in
// community B. Unmapped host, lookup failure, and a workflow that does not
// exist in *this* community all fail closed with the same generic 404, so a
// caller cannot probe which hosts or workflow ids exist on other tenants.
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(|_| not_found("workflow not found"))?;
let community_id = tenant.community();
let workflow = state
.db
.get_workflow(community_id, id)
.await
.map_err(|_| not_found("workflow not found"))?;
let def: buzz_workflow::WorkflowDef = serde_json::from_value(workflow.definition.clone())
.map_err(|e| super::internal_error(&format!("corrupt workflow definition: {e}")))?;
if !matches!(def.trigger, buzz_workflow::TriggerDef::Webhook) {
return Err(api_error(
StatusCode::BAD_REQUEST,
"workflow does not have a webhook trigger",
));
}
// Verify webhook secret. Prefer header (not logged by proxies); fall back to query param.
let stored_secret = crate::webhook_secret::extract_secret(&workflow.definition);
let provided_secret = headers
.get("x-webhook-secret")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.or_else(|| query.secret.clone())
.unwrap_or_default();
match &stored_secret {
Some(secret) => {
if !crate::webhook_secret::verify_secret(&provided_secret, secret) {
tracing::warn!("webhook: invalid secret for workflow {id}");
return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed"));
}
}
None => {
return Err(api_error(
StatusCode::UNAUTHORIZED,
"webhook secret required but not configured — re-save the workflow to generate one",
));
}
}
// Parse optional JSON body as trigger context.
let body_json: Option<Value> =
if body.is_empty() {
None
} else {
Some(serde_json::from_slice(&body).map_err(|e| {
api_error(StatusCode::BAD_REQUEST, &format!("invalid JSON body: {e}"))
})?)
};
// Build trigger context from webhook body fields.
let mut trigger_ctx = buzz_workflow::executor::TriggerContext {
channel_id: workflow
.channel_id
.map(|ch| ch.to_string())
.unwrap_or_default(),
..Default::default()
};
if let Some(Value::Object(ref map)) = body_json {
for (k, v) in map {
let val_str = match v {
Value::String(s) => s.clone(),
other => other.to_string(),
};
trigger_ctx.webhook_fields.insert(k.clone(), val_str);
}
}
let trigger_ctx_json = serde_json::to_value(&trigger_ctx).ok();
// SEC-006: the webhook secret authenticates the *caller*, but the run
// executes with the workflow **owner's** standing authority — so the
// secret alone is insufficient. Immediately before run creation, reject
// disabled/inactive workflows and recheck the owner's current channel
// membership (and role, for exfiltration-capable definitions). Fail
// closed with the same generic 404 as the lookups above so a
// revoked-owner workflow is indistinguishable from a nonexistent one.
if !workflow.enabled || workflow.status != buzz_db::workflow::WorkflowStatus::Active {
return Err(not_found("workflow not found"));
}
let Some(wf_channel_id) = workflow.channel_id else {
// No channel scope means no channel authority to verify — fail closed.
return Err(not_found("workflow not found"));
};
state
.workflow_engine
.check_owner_authority(community_id, wf_channel_id, &workflow.owner_pubkey, &def)
.await
.map_err(|_| not_found("workflow not found"))?;
let run_id = state
.db
.create_workflow_run(community_id, id, None, trigger_ctx_json.as_ref())
.await
.map_err(|e| super::internal_error(&format!("db error: {e}")))?;
// Spawn workflow execution asynchronously.
let engine = Arc::clone(&state.workflow_engine);
let db = state.db.clone();
let def_value = workflow.definition.clone();
let trigger_ctx_clone = trigger_ctx.clone();
tokio::spawn(async move {
let def: buzz_workflow::WorkflowDef = match serde_json::from_value(def_value) {
Ok(d) => d,
Err(e) => {
tracing::error!("webhook: failed to parse definition: {e}");
if let Err(db_err) = db
.update_workflow_run(
community_id,
run_id,
buzz_db::workflow::RunStatus::Failed,
0,
&serde_json::json!([]),
Some(buzz_db::workflow::WorkflowRunFailure {
code: "invalid_definition",
message: &format!("definition parse error: {e}"),
}),
)
.await
{
tracing::error!("webhook: failed to mark run as failed: {db_err}");
}
return;
}
};
let result = buzz_workflow::executor::execute_from_step(
&engine,
community_id,
run_id,
&def,
&trigger_ctx_clone,
0,
None,
)
.await;
engine
.finalize_run(community_id, run_id, result, None)
.await;
});
Ok((
StatusCode::ACCEPTED,
Json(serde_json::json!({
"run_id": run_id.to_string(),
"workflow_id": id.to_string(),
"status": "pending",
})),
))
}
/// If all filters target kind:20001 or kind:40902 with authors, synthesize
/// presence from Redis instead of querying the DB (ephemeral events are never
/// stored, and kind:40902 snapshots are relay-generated on demand).
///
/// Returns `None` when the filters are not a presence query (fall through to
/// the normal query path). Returns `Some(Ok(events))` when a presence snapshot
/// was produced — an empty vec is an authoritative "all offline" answer.
/// Returns `Some(Err(_))` when the backing Redis lookup failed: callers must
/// propagate that as an error response rather than a fake-empty success, so a
/// consumer cannot mistake a backend outage for an authoritative snapshot.
#[allow(clippy::type_complexity)]
async fn synthesize_presence(
pubsub: &buzz_pubsub::PubSubManager,
relay_keypair: &nostr::Keys,
tenant: &buzz_core::tenant::TenantContext,
filters: &[nostr::Filter],
) -> Option<Result<Vec<Value>, (StatusCode, Json<Value>)>> {
use buzz_core::kind::{KIND_PRESENCE_SNAPSHOT, KIND_PRESENCE_UPDATE};
// Only intercept if every filter targets kind:20001 or 40902 with authors.
let mut all_pubkeys: Vec<nostr::PublicKey> = Vec::new();
for filter in filters {
let kinds = filter.kinds.as_ref()?;
let only_kind = kinds.iter().next()?;
let k = only_kind.as_u16() as u32;
if kinds.len() != 1 || (k != KIND_PRESENCE_UPDATE && k != KIND_PRESENCE_SNAPSHOT) {
return None;
}
let authors = filter.authors.as_ref()?;
if authors.is_empty() {
return None;
}
all_pubkeys.extend(authors.iter().copied());
}
if all_pubkeys.is_empty() {
return Some(Ok(Vec::new()));
}
// Dedup pubkeys.
all_pubkeys.sort_by_key(|pk| pk.to_hex());
all_pubkeys.dedup();
// Look up Redis. A lookup failure must surface as an error, not a
// fake-empty success — otherwise a Redis outage is indistinguishable from
// an authoritative all-offline snapshot to the consumer.
let presence_map = match pubsub.get_presence_bulk(tenant, &all_pubkeys).await {
Ok(map) => map,
Err(e) => return Some(Err(internal_error(&format!("presence lookup: {e}")))),
};
if presence_map.is_empty() {
return Some(Ok(Vec::new()));
}
// Synthesize kind:20001 events signed by the relay.
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut events = Vec::with_capacity(presence_map.len());
for (pubkey_hex, status) in &presence_map {
// Build a synthetic event: relay-signed, content = status, p-tag = subject.
// A build/sign failure here is an internal fault, not a "not a presence
// query" signal, so surface it as an error rather than falling through.
let tags = match nostr::Tag::parse(["p", pubkey_hex]) {
Ok(tag) => vec![tag],
Err(e) => return Some(Err(internal_error(&format!("presence tag: {e}")))),
};
let event = match nostr::EventBuilder::new(
nostr::Kind::Custom(KIND_PRESENCE_UPDATE as u16),
status,
)
.tags(tags)
.custom_created_at(nostr::Timestamp::from(now))
.sign_with_keys(relay_keypair)
{
Ok(event) => event,
Err(e) => return Some(Err(internal_error(&format!("presence sign: {e}")))),
};
if let Ok(v) = serde_json::to_value(&event) {
events.push(v);
}
}
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`/
// `community_bans`) are not nostr events, so they are served over dedicated
// NIP-98-authed GET endpoints rather than the REQ/`/query` path (which would
// force a synthetic event shape and thread a privileged branch onto the shared
// read hot path). Gated on `ModerationAction::ViewQueue` via the one capability
// helper — never an inline role check. Host-scoped: community from the request
// host, no channel context (queue reads are community-wide).
/// Shared prelude for a moderation read: bind tenant, verify NIP-98 GET auth,
/// replay-check, and confirm the caller may view the queue.
///
/// `raw_query` is the request's raw query string (from [`axum::extract::RawQuery`]),
/// e.g. `Some("limit=20&status=open")`. NIP-98 signs the *full* request URL, so the
/// client's `u` tag includes any query string; the expected URL reconstructed here
/// must therefore append the same query verbatim or query-bearing reads
/// (`reports?limit=…`, `audit?limit=…`) 401 on a URL mismatch. Query-less reads
/// (`restricted`) pass `None` and keep the bare-path expectation. The verbatim
/// request query is used (not a re-serialized parse) so the match stays byte-exact
/// with what the client signed regardless of param order or encoding.
async fn authorize_moderation_read(
state: &Arc<AppState>,
headers: &HeaderMap,
path: &str,
raw_query: Option<&str>,
) -> Result<TenantContext, (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(
StatusCode::NOT_FOUND,
"relay: no community is configured for this host",
)
})?;
let path_with_query = match raw_query {
Some(q) if !q.is_empty() => format!("{path}?{q}"),
_ => path.to_string(),
};
let url = nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query);
let VerifiedBridgeAuth {
pubkey,
event_id_bytes,
..
} = verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?;
check_nip98_replay(state, &tenant, event_id_bytes).await?;
let pubkey_bytes = pubkey.to_bytes().to_vec();
crate::handlers::moderation_authz::authorize_moderation_action(
&tenant,
state,
&pubkey_bytes,
None,
crate::handlers::moderation_authz::ModerationTarget::None,
crate::handlers::moderation_authz::ModerationAction::ViewQueue,
)
.await
.map_err(|_| {
api_error(
StatusCode::FORBIDDEN,
"restricted: moderator access required",
)
})?;
Ok(tenant)
}
/// Cap on rows returned by a single moderation read.
const MODERATION_READ_LIMIT: i64 = 500;
/// Optional `?status=` and `?limit=` query for moderation reads.
#[derive(serde::Deserialize, Default)]
pub struct ModerationReadQuery {
status: Option<String>,
limit: Option<i64>,
}
fn clamp_limit(requested: Option<i64>) -> i64 {
requested
.filter(|n| *n > 0)
.map(|n| n.min(MODERATION_READ_LIMIT))
.unwrap_or(MODERATION_READ_LIMIT)
}
/// `GET /moderation/reports` — the moderation queue (NIP-98 + mod-authz).
pub async fn moderation_reports(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
RawQuery(raw_query): RawQuery,
Query(q): Query<ModerationReadQuery>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let tenant = authorize_moderation_read(
&state,
&headers,
"/moderation/reports",
raw_query.as_deref(),
)
.await?;
let rows = state
.db
.list_moderation_reports(
tenant.community(),
q.status.as_deref(),
clamp_limit(q.limit),
)
.await
.map_err(|e| internal_error(&format!("list reports: {e}")))?;
Ok(Json(Value::Array(rows.iter().map(report_json).collect())))
}
/// `GET /moderation/audit` — the moderation audit log (NIP-98 + mod-authz).
pub async fn moderation_audit(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
RawQuery(raw_query): RawQuery,
Query(q): Query<ModerationReadQuery>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let tenant =
authorize_moderation_read(&state, &headers, "/moderation/audit", raw_query.as_deref())
.await?;
let rows = state
.db
.list_moderation_actions(tenant.community(), clamp_limit(q.limit))
.await
.map_err(|e| internal_error(&format!("list actions: {e}")))?;
Ok(Json(Value::Array(rows.iter().map(action_json).collect())))
}
/// `GET /moderation/restricted` — currently banned/timed-out members.
pub async fn moderation_restricted(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let tenant =
authorize_moderation_read(&state, &headers, "/moderation/restricted", None).await?;
let rows = state
.db
.list_community_restrictions(tenant.community())
.await
.map_err(|e| internal_error(&format!("list restrictions: {e}")))?;
Ok(Json(Value::Array(rows.iter().map(ban_json).collect())))
}
fn report_json(r: &buzz_db::moderation::ReportRecord) -> Value {
let (target_kind, target) = match &r.target {
buzz_db::moderation::ReportTarget::Event(id) => ("event", hex::encode(id)),
buzz_db::moderation::ReportTarget::Pubkey(pk) => ("pubkey", hex::encode(pk)),
buzz_db::moderation::ReportTarget::Blob(sha) => ("blob", hex::encode(sha)),
};
serde_json::json!({
"id": r.id,
"report_event_id": hex::encode(&r.report_event_id),
"reporter_pubkey": hex::encode(&r.reporter_pubkey),
"target_kind": target_kind,
"target": target,
"channel_id": r.channel_id,
"report_type": r.report_type,
"note": r.note,
"status": r.status,
"resolved_by": r.resolved_by.as_ref().map(hex::encode),
"resolved_at": r.resolved_at,
"action_id": r.action_id,
"created_at": r.created_at,
})
}
fn action_json(a: &buzz_db::moderation::ActionRecord) -> Value {
serde_json::json!({
"id": a.id,
"actor_pubkey": hex::encode(&a.actor_pubkey),
"action": a.action,
"target_pubkey": a.target_pubkey.as_ref().map(hex::encode),
"target_event_id": a.target_event_id.as_ref().map(hex::encode),
"channel_id": a.channel_id,
"reason_code": a.reason_code,
"public_reason": a.public_reason,
"private_reason": a.private_reason,
"matched_principal": a.matched_principal,
"created_at": a.created_at,
})
}
fn ban_json(b: &buzz_db::moderation::BanRecord) -> Value {
serde_json::json!({
"pubkey": hex::encode(&b.pubkey),
"banned": b.banned,
"ban_expires_at": b.ban_expires_at,
"ban_reason": b.ban_reason,
"muted_until": b.muted_until,
"mute_reason": b.mute_reason,
"actor_pubkey": hex::encode(&b.actor_pubkey),
"updated_at": b.updated_at,
})
}
#[cfg(test)]
mod postgres_tests {
use super::*;
use nostr::{Alphabet, EventBuilder, Keys, Kind, SingleLetterTag, Tag};
use std::sync::Mutex;
fn redis_pool() -> deadpool_redis::Pool {
let url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".into());
deadpool_redis::Config::from_url(url)
.create_pool(Some(deadpool_redis::Runtime::Tokio1))
.expect("create redis pool")
}
fn fresh_tenant(host: &str) -> TenantContext {
TenantContext::resolved(
buzz_core::CommunityId::from_uuid(uuid::Uuid::new_v4()),
host,
)
}
fn fresh_nip98_event_id_bytes() -> [u8; 32] {
EventBuilder::new(Kind::HttpAuth, "")
.sign_with_keys(&Keys::generate())
.expect("sign auth event")
.id
.to_bytes()
}
#[test]
fn bridge_detects_mixed_search_and_non_search_filters() {
let filters = vec![
nostr::Filter::new().search("hello"),
nostr::Filter::new().kind(Kind::TextNote),
];
assert!(has_mixed_search_filters(&filters));
}
#[test]
fn bridge_accepts_all_search_filters() {
let filters = vec![
nostr::Filter::new().search("hello"),
nostr::Filter::new().search("world"),
];
assert!(!has_mixed_search_filters(&filters));
}
#[test]
fn bridge_accepts_all_non_search_filters() {
let filters = vec![
nostr::Filter::new().kind(Kind::TextNote),
nostr::Filter::new().kind(Kind::Metadata),
];
assert!(!has_mixed_search_filters(&filters));
}
/// Production-wiring seam for the Redis-outage boundary. Drives the real
/// `synthesize_presence` with a `PubSubManager` whose pool points at a
/// closed port, so the `get_presence_bulk` lookup fails. A presence-snapshot
/// filter must yield `Some(Err(500))` — never `Some(Ok([]))`, which would
/// let a consumer mistake a backend outage for an authoritative all-offline
/// snapshot. Restoring `unwrap_or_default()` inside `synthesize_presence`
/// turns this red (it would return `Some(Ok([]))`), which is what protects
/// the error-mapping seam Thufir found otherwise mutation-unprotected.
#[tokio::test]
async fn synthesize_presence_surfaces_redis_failure_as_error_response() {
use buzz_core::kind::KIND_PRESENCE_SNAPSHOT;
// Pool at a closed port: get_presence_bulk's connection attempt fails.
let dead_pool = deadpool_redis::Config::from_url("redis://127.0.0.1:1")
.create_pool(Some(deadpool_redis::Runtime::Tokio1))
.expect("pool builds lazily");
let pubsub = buzz_pubsub::PubSubManager::new("redis://127.0.0.1:1", dead_pool)
.await
.expect("PubSubManager::new performs no IO");
let relay_keypair = Keys::generate();
let tenant = fresh_tenant("relay.example");
// A presence-snapshot query for a concrete author reaches the Redis
// lookup (an empty author set would short-circuit to an empty snapshot).
let filters = vec![nostr::Filter::new()
.kind(Kind::Custom(KIND_PRESENCE_SNAPSHOT as u16))
.author(Keys::generate().public_key())];
let result = synthesize_presence(&pubsub, &relay_keypair, &tenant, &filters).await;
match result {
Some(Err((status, _))) => assert_eq!(
status,
StatusCode::INTERNAL_SERVER_ERROR,
"a Redis lookup failure must surface as HTTP 500"
),
other => panic!(
"a Redis outage must yield Some(Err(500)), not a fake-empty success: {other:?}"
),
}
}
#[test]
fn thread_aux_query_targets_root_and_replies() {
let tenant = fresh_tenant("relay.example");
let targets = vec!["root".to_string(), "reply".to_string()];
let query = build_aux_query(tenant.community(), targets.clone(), &WINDOW_AUX_KINDS);
assert_eq!(query.e_tags, Some(targets));
assert_eq!(
query.kinds,
Some(WINDOW_AUX_KINDS.iter().map(|kind| *kind as i32).collect())
);
assert_eq!(query.limit, None);
assert_eq!(query.until, None);
assert_eq!(query.before_id, None);
}
fn aux_event(keys: &Keys, created_at: u64, content: &str) -> buzz_core::StoredEvent {
let ev = EventBuilder::new(Kind::Custom(7), content)
.custom_created_at(nostr::Timestamp::from(created_at))
.sign_with_keys(keys)
.unwrap();
buzz_core::StoredEvent::new(ev, None)
}
/// Carl/#6572: a one-shot `limit=1000` aux query is newest-first, so the
/// oldest reactions/edits/deletions past the clamp vanished. The paged
/// drain must walk the keyset cursor until a short page and return every
/// event exactly once.
#[tokio::test]
async fn query_all_pages_drains_past_the_page_clamp() {
let keys = Keys::generate();
// Newest-first store: 5 events, two sharing a second so the id
// tiebreak is exercised.
let mut store = [
aux_event(&keys, 50, "e"),
aux_event(&keys, 40, "d1"),
aux_event(&keys, 40, "d2"),
aux_event(&keys, 30, "c"),
aux_event(&keys, 10, "a"),
];
store.sort_by(|l, r| {
r.event
.created_at
.cmp(&l.event.created_at)
.then(l.event.id.cmp(&r.event.id))
});
let expected: Vec<_> = store.iter().map(|se| se.event.id).collect();
let mut calls = Vec::new();
let tenant = fresh_tenant("relay.example");
let query = build_aux_query(tenant.community(), vec!["root".into()], &WINDOW_AUX_KINDS);
let mut fetch = |q: &buzz_db::EventQuery| {
calls.push((q.limit, q.until, q.before_id.clone()));
// Emulate `query_events_on`: `created_at < until OR
// (created_at = until AND id > before_id)`, newest-first, limit.
let page: Vec<_> = store
.iter()
.filter(|se| match (q.until, q.before_id.as_deref()) {
(Some(until), Some(before)) => {
let ts = se.event.created_at.as_secs() as i64;
ts < until.timestamp()
|| (ts == until.timestamp()
&& se.event.id.as_bytes().as_slice() > before)
}
_ => true,
})
.take(q.limit.unwrap() as usize)
.cloned()
.collect();
page
};
let events = query_all_pages(query, 2, &mut AuxReader::Fake(&mut fetch))
.await
.unwrap();
assert_eq!(
events.iter().map(|se| se.event.id).collect::<Vec<_>>(),
expected
);
assert_eq!(calls.len(), 3, "2 full pages + 1 short page");
assert!(calls.iter().all(|(limit, _, _)| *limit == Some(2)));
assert_eq!(calls[0].1, None);
// Second page resumes from the last row of the first (ts 40, larger id).
assert_eq!(calls[1].1.unwrap().timestamp(), 40);
assert_eq!(
calls[1].2.as_deref(),
Some(store[1].event.id.as_bytes().as_slice())
);
assert_eq!(calls[2].1.unwrap().timestamp(), 30);
}
#[tokio::test]
async fn query_all_pages_stops_at_one_short_page() {
let tenant = fresh_tenant("relay.example");
let query = build_aux_query(tenant.community(), vec!["root".into()], &WINDOW_AUX_KINDS);
let mut calls = 0;
let mut fetch = |_q: &buzz_db::EventQuery| {
calls += 1;
Vec::new()
};
let events = query_all_pages(query, 1000, &mut AuxReader::Fake(&mut fetch))
.await
.unwrap();
assert!(events.is_empty());
assert_eq!(calls, 1);
}
#[test]
fn bridge_search_mode_extension_defaults_to_full_text() {
assert_eq!(
extract_search_mode(&serde_json::json!({ "search": "pro" })),
buzz_search::SearchMode::FullText
);
assert_eq!(
extract_search_mode(&serde_json::json!({ "search": "pro", "search_mode": "word" })),
buzz_search::SearchMode::FullText
);
}
#[test]
fn bridge_search_mode_extension_accepts_prefix_snake_or_camel_case() {
assert_eq!(
extract_search_mode(&serde_json::json!({ "search": "pro", "search_mode": "prefix" })),
buzz_search::SearchMode::Prefix
);
assert_eq!(
extract_search_mode(&serde_json::json!({ "search": "pro", "searchMode": "prefix" })),
buzz_search::SearchMode::Prefix
);
}
/// Attack 3 proof: two stateless relay pods sharing Redis must share one
/// community-scoped NIP-98 seen-set. Pod A's first claim succeeds; pod B's
/// replay of the same event id in the same community is rejected. The same
/// id in a different community still succeeds, proving the key is scoped by
/// server-resolved tenant rather than global process memory.
async fn nip98_replay_guard_rejects_cross_pod_replay_on_bridge_path() {
let pool = redis_pool();
let pod_a = buzz_pubsub::RedisNip98ReplayGuard::new(pool.clone());
let pod_b = buzz_pubsub::RedisNip98ReplayGuard::new(pool);
let tenant_a = fresh_tenant("relay-a.example");
let tenant_b = fresh_tenant("relay-b.example");
let event_id_bytes = fresh_nip98_event_id_bytes();
check_nip98_replay_with_guard(&pod_a, &tenant_a, event_id_bytes)
.await
.expect("first pod should claim fresh NIP-98 event id");
let (status, _) = check_nip98_replay_with_guard(&pod_b, &tenant_a, event_id_bytes)
.await
.expect_err("second pod must reject same-community replay");
assert_eq!(status, StatusCode::UNAUTHORIZED);
check_nip98_replay_with_guard(&pod_b, &tenant_b, event_id_bytes)
.await
.expect("same event id in a different community uses a distinct seen-set");
}
/// Attack 3 same-pod regression guard: replacing the process-local moka
/// cache with a shared Redis seen-set must not weaken same-pod replay
/// rejection. A single guard instance, called twice with the same
/// `TenantContext` and the same event id, MUST reject the second call.
/// Bites if `try_mark`'s admit/reject mapping is reversed or no-op'd.
async fn nip98_replay_guard_rejects_same_pod_same_community_replay() {
let pool = redis_pool();
let pod = buzz_pubsub::RedisNip98ReplayGuard::new(pool);
let tenant = fresh_tenant("relay-a.example");
let event_id_bytes = fresh_nip98_event_id_bytes();
check_nip98_replay_with_guard(&pod, &tenant, event_id_bytes)
.await
.expect("first claim on a fresh event id must succeed");
let (status, _) = check_nip98_replay_with_guard(&pod, &tenant, event_id_bytes)
.await
.expect_err("same-pod replay of the same id+community must reject");
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
mod external_infra_redis_tests {
#[tokio::test]
#[ignore = "requires Redis"]
async fn nip98_replay_guard_rejects_cross_pod_replay_on_bridge_path() {
super::nip98_replay_guard_rejects_cross_pod_replay_on_bridge_path().await;
}
#[tokio::test]
#[ignore = "requires Redis"]
async fn nip98_replay_guard_rejects_same_pod_same_community_replay() {
super::nip98_replay_guard_rejects_same_pod_same_community_replay().await;
}
}
/// Attack 3 fail-closed guard: a stateless worker that loses Redis MUST
/// reject the request, never admit it. The shared seen-set is the
/// freshness fence; degrading to "best effort, allow on error" forfeits
/// the proof (per the `Nip98ReplayGuard` trait contract,
/// `buzz-auth/src/nip98_replay.rs:70-73`).
///
/// This test does not require Redis — it injects a guard that always
/// returns `Err`, exercising the `Err =>` arm in
/// `check_nip98_replay_with_guard` directly. Bites if the arm is changed
/// to admit (`Ok(())` / `Ok(true)`) instead of returning 401.
#[tokio::test]
async fn nip98_replay_check_fails_closed_when_guard_errors() {
use buzz_auth::AuthError;
use nostr::EventId;
use std::future::Future;
use std::pin::Pin;
struct AlwaysErrGuard;
impl Nip98ReplayGuard for AlwaysErrGuard {
fn try_mark_in_scope<'a>(
&'a self,
_scope: &'a str,
_event_id: &'a EventId,
_ttl_secs: u64,
) -> Pin<Box<dyn Future<Output = Result<bool, AuthError>> + Send + 'a>> {
Box::pin(async {
Err(AuthError::Internal(
"simulated Redis pool acquire failure".into(),
))
})
}
}
let guard = AlwaysErrGuard;
let tenant = fresh_tenant("relay-a.example");
let event_id_bytes = fresh_nip98_event_id_bytes();
let (status, body) = check_nip98_replay_with_guard(&guard, &tenant, event_id_bytes)
.await
.expect_err("guard error MUST fail closed, never admit");
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
"fail-closed must return 401"
);
let msg = body
.get("error")
.and_then(|v| v.as_str())
.unwrap_or_default();
assert!(
msg.contains("replay check unavailable"),
"fail-closed body must carry the unavailable signal so callers can \
distinguish unavailability from replay; got body = {body:?}"
);
}
/// Build a signed NIP-98 event JSON string for `url` + `method`, mirroring
/// `buzz_auth::nip98::tests::make_nip98_event` so the bridge tests don't
/// reach into buzz-auth's test scope.
fn build_nip98_event_json(keys: &Keys, url: &str, method: &str) -> String {
let tags = vec![
Tag::parse(["u", url]).expect("u tag"),
Tag::parse(["method", method]).expect("method tag"),
];
let event = EventBuilder::new(Kind::HttpAuth, "")
.tags(tags)
.sign_with_keys(keys)
.expect("sign NIP-98 event");
serde_json::to_string(&event).expect("serialize")
}
/// Build a `HeaderMap` with the NIP-98 event base64-encoded in
/// `Authorization: Nostr <base64>`, matching the production bridge auth
/// header shape.
fn nip98_auth_headers(event_json: &str) -> axum::http::HeaderMap {
use base64::engine::general_purpose::STANDARD as BASE64;
let mut headers = axum::http::HeaderMap::new();
let value = format!("Nostr {}", BASE64.encode(event_json.as_bytes()));
headers.insert(
axum::http::header::AUTHORIZATION,
value.parse().expect("valid header value"),
);
headers
}
/// Row 44 obligation: a NIP-98 event signed against community A's host
/// MUST be rejected at the bridge when the request resolves to community
/// B's host. The conformance text in `docs/multi-tenant-conformance.md`
/// states: "NIP-98 `u` URL host must match `req.community`". Before this
/// gap closed, `expected_url` was derived from `state.config.relay_url`
/// (one static string per deployment), so any request to *any* host on a
/// multi-tenant deployment would verify against community A's URL — both
/// admitting cross-host forgeries (event signed for A presented at B) and
/// rejecting every legitimate request whose community host wasn't the
/// single configured one.
///
/// This test bites if `nip98_expected_url` is reverted to use
/// `config.relay_url`'s host (the original `canonical_url` behavior).
#[test]
fn verify_bridge_auth_rejects_nip98_event_signed_for_wrong_communitys_host() {
let keys = Keys::generate();
// Client signs an event for community A's host, then presents it at a
// request whose `Host` header resolved to community B.
let signed_url = "https://host-a.example/events";
let event_json = build_nip98_event_json(&keys, signed_url, "POST");
let headers = nip98_auth_headers(&event_json);
let config_relay_url = "wss://host-a.example"; // doesn't matter — only used for scheme.
let tenant_b = fresh_tenant("host-b.example");
let expected_url = nip98_expected_url(config_relay_url, &tenant_b, "/events");
let (status, body) = verify_bridge_auth(&headers, "POST", &expected_url, Some(b""), true)
.expect_err(
"cross-host NIP-98 event MUST be rejected — row 44: `u` URL host \
must match req.community",
);
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
"cross-host rejection must be a 401, not silently admitted"
);
let msg = body
.get("error")
.and_then(|v| v.as_str())
.unwrap_or_default();
assert!(
msg.contains("URL mismatch"),
"rejection must carry the URL-mismatch signal so callers can \
distinguish it from other auth failures; got body = {body:?}"
);
}
#[test]
fn verify_bridge_auth_can_require_payload_tag_for_json_body_endpoints() {
let keys = Keys::generate();
let signed_url = "https://host-a.example/operator/communities";
let event_json = build_nip98_event_json(&keys, signed_url, "POST");
let headers = nip98_auth_headers(&event_json);
let (status, body) = verify_bridge_auth_with_options(
&headers,
"POST",
signed_url,
Some(br#"{"host":"created.example"}"#),
true,
true,
)
.expect_err("body-bearing operator requests must require a payload tag");
assert_eq!(status, StatusCode::UNAUTHORIZED);
let msg = body
.get("error")
.and_then(|v| v.as_str())
.unwrap_or_default();
assert!(
msg.contains("missing payload tag"),
"rejection should explain the payload binding failure; got body = {body:?}"
);
}
/// Positive control for the cross-host test: a NIP-98 event signed for
/// host A MUST be accepted at a request whose tenant resolved to host A.
/// Without this, the cross-host test could be passing vacuously (e.g. if
/// `nip98_expected_url` always produced a URL no event could match).
#[test]
fn verify_bridge_auth_accepts_nip98_event_signed_for_matching_host() {
let keys = Keys::generate();
let signed_url = "https://host-a.example/events";
let event_json = build_nip98_event_json(&keys, signed_url, "POST");
let headers = nip98_auth_headers(&event_json);
// Configured relay URL deliberately differs in host from the request's
// tenant host — proving the helper uses `tenant.host()`, not the config.
let config_relay_url = "wss://other-config-host.example";
let tenant_a = fresh_tenant("host-a.example");
let expected_url = nip98_expected_url(config_relay_url, &tenant_a, "/events");
let VerifiedBridgeAuth {
pubkey,
signed_created_at,
..
} = verify_bridge_auth(&headers, "POST", &expected_url, Some(b""), true)
.expect("matching-host NIP-98 event must verify");
assert_eq!(
pubkey,
keys.public_key(),
"returned pubkey must be the signer's"
);
assert!(
signed_created_at.is_some(),
"verified NIP-98 auth must retain its signed timestamp"
);
}
/// Mirror of the query-reconstruction `authorize_moderation_read` performs
/// before calling [`nip98_expected_url`], so the tests below pin the exact
/// seam without a DB harness. Kept in lockstep with the production match arm.
fn moderation_read_expected_url(
config_relay_url: &str,
tenant: &TenantContext,
path: &str,
raw_query: Option<&str>,
) -> String {
let path_with_query = match raw_query {
Some(q) if !q.is_empty() => format!("{path}?{q}"),
_ => path.to_string(),
};
nip98_expected_url(config_relay_url, tenant, &path_with_query)
}
/// L7 read-auth blocker (Wren, #1591 sweep): the CLI signs the *full*
/// request URL — including `?limit=…&status=…` — but the relay used to
/// reconstruct the expected URL from the bare path only, so
/// `buzz moderation reports` / `audit` 401'd on a NIP-98 URL mismatch in
/// normal use. This pins that a query-bearing GET verifies iff the expected
/// URL carries the same query verbatim. Bites if the query is ever dropped
/// from `authorize_moderation_read`'s expected-URL reconstruction.
#[test]
fn moderation_read_query_bearing_nip98_event_verifies_with_matching_query() {
let keys = Keys::generate();
// CLI signs the URL it actually requests, query and all.
let signed_url = "https://host-a.example/moderation/reports?limit=20&status=open";
let event_json = build_nip98_event_json(&keys, signed_url, "GET");
let headers = nip98_auth_headers(&event_json);
let tenant_a = fresh_tenant("host-a.example");
let expected_url = moderation_read_expected_url(
"wss://config-host.example",
&tenant_a,
"/moderation/reports",
Some("limit=20&status=open"),
);
let VerifiedBridgeAuth { pubkey, .. } =
verify_bridge_auth(&headers, "GET", &expected_url, None, true)
.expect("query-bearing moderation read must verify against the same query");
assert_eq!(pubkey, keys.public_key());
}
/// Anti-regression control proving the fix is load-bearing: the same
/// query-bearing event MUST be rejected when the expected URL omits the
/// query — the pre-fix behavior. If this ever passes, the relay has
/// silently reverted to bare-path reconstruction.
#[test]
fn moderation_read_query_bearing_nip98_event_rejected_against_bare_path() {
let keys = Keys::generate();
let signed_url = "https://host-a.example/moderation/reports?limit=20&status=open";
let event_json = build_nip98_event_json(&keys, signed_url, "GET");
let headers = nip98_auth_headers(&event_json);
let tenant_a = fresh_tenant("host-a.example");
// No query — the broken pre-fix reconstruction.
let bare_url = moderation_read_expected_url(
"wss://config-host.example",
&tenant_a,
"/moderation/reports",
None,
);
let (status, body) = verify_bridge_auth(&headers, "GET", &bare_url, None, true)
.expect_err("query-signed event MUST NOT match a bare-path expected URL");
assert_eq!(status, StatusCode::UNAUTHORIZED);
let msg = body
.get("error")
.and_then(|v| v.as_str())
.unwrap_or_default();
assert!(
msg.contains("URL mismatch"),
"rejection must be a URL mismatch; got body = {body:?}"
);
}
/// `audit?limit=20` — the second query-bearing read path — verifies the
/// same way. Pins that the reconstruction is generic over the path, not
/// special-cased to `reports`.
#[test]
fn moderation_read_audit_query_bearing_nip98_event_verifies() {
let keys = Keys::generate();
let signed_url = "https://host-a.example/moderation/audit?limit=20";
let event_json = build_nip98_event_json(&keys, signed_url, "GET");
let headers = nip98_auth_headers(&event_json);
let tenant_a = fresh_tenant("host-a.example");
let expected_url = moderation_read_expected_url(
"wss://config-host.example",
&tenant_a,
"/moderation/audit",
Some("limit=20"),
);
let VerifiedBridgeAuth { pubkey, .. } =
verify_bridge_auth(&headers, "GET", &expected_url, None, true)
.expect("audit query-bearing read must verify");
assert_eq!(pubkey, keys.public_key());
}
/// `restricted` has no query and passes `None`, so its expected URL stays
/// the bare path — a query-less signed event verifies. Pins Wren's
/// "preserve restricted no-query behavior" checklist item.
#[test]
fn moderation_read_restricted_no_query_still_verifies() {
let keys = Keys::generate();
let signed_url = "https://host-a.example/moderation/restricted";
let event_json = build_nip98_event_json(&keys, signed_url, "GET");
let headers = nip98_auth_headers(&event_json);
let tenant_a = fresh_tenant("host-a.example");
let expected_url = moderation_read_expected_url(
"wss://config-host.example",
&tenant_a,
"/moderation/restricted",
None,
);
assert_eq!(expected_url, "https://host-a.example/moderation/restricted");
let VerifiedBridgeAuth { pubkey, .. } =
verify_bridge_auth(&headers, "GET", &expected_url, None, true)
.expect("query-less restricted read must verify against the bare path");
assert_eq!(pubkey, keys.public_key());
}
/// `nip98_expected_url` derives host from `tenant`, not from
/// `config_relay_url`. Pin both directions: changing the tenant's host
/// changes the output; changing the config's host does NOT.
#[test]
fn nip98_expected_url_uses_tenant_host_not_config_host() {
let tenant_a = fresh_tenant("host-a.example");
let tenant_b = fresh_tenant("host-b.example");
let url_a = nip98_expected_url("wss://config-host.example", &tenant_a, "/events");
let url_b = nip98_expected_url("wss://config-host.example", &tenant_b, "/events");
assert_eq!(url_a, "https://host-a.example/events");
assert_eq!(url_b, "https://host-b.example/events");
// Same tenant, two different config hosts → output is identical.
// (If config-host ever leaked into the URL, this assertion would bite.)
let url_a_alt_config =
nip98_expected_url("wss://different-config.example", &tenant_a, "/events");
assert_eq!(
url_a, url_a_alt_config,
"config-relay-url's host MUST NOT influence the NIP-98 expected URL — \
only its scheme contributes"
);
}
/// `nip98_expected_url` derives scheme from `config_relay_url`'s prefix:
/// `wss://` → `https`, everything else → `http`. Deployments that run
/// `ws://` in dev/test still need a NIP-98 URL the client can sign against.
#[test]
fn nip98_expected_url_derives_scheme_from_config() {
let tenant = fresh_tenant("host-a.example");
assert_eq!(
nip98_expected_url("wss://config.example", &tenant, "/events"),
"https://host-a.example/events",
"wss:// production config → https:// URL"
);
assert_eq!(
nip98_expected_url("ws://config.example", &tenant, "/events"),
"http://host-a.example/events",
"ws:// dev config → http:// URL"
);
}
// ----- NIP-42 host-binding tests (sibling of NIP-98 row 44 obligation) -----
/// Sign a NIP-42 AUTH event with `relay` tag = `relay_url`, then verify
/// it against `expected_relay_url`. Returns the `verify_nip42_event` result.
fn verify_nip42_with_urls(
challenge: &str,
signed_relay_url: &str,
expected_relay_url: &str,
) -> Result<(), buzz_auth::AuthError> {
let keys = Keys::generate();
let parsed = nostr::RelayUrl::parse(signed_relay_url).expect("valid relay url");
let event = EventBuilder::auth(challenge, parsed)
.sign_with_keys(&keys)
.expect("sign auth event");
buzz_auth::nip42::verify_nip42_event(&event, challenge, expected_relay_url)
}
/// Row 44 obligation (WS side): a NIP-42 AUTH event signed against
/// community A's host MUST be rejected on a connection whose tenant
/// resolved to community B's host. Before this gap closed, `handle_auth`
/// verified against `state.config.relay_url` (one static string per
/// deployment), so a token-of-A presented on B's connection would pass —
/// the cross-host hole `nip98_expected_url` already closed on the HTTP
/// side, mirrored here on the WS side.
///
/// Scenario: a multi-tenant deployment whose `config.relay_url` is set to
/// community A's host (a realistic accident — config can only hold one
/// host). An attacker on a B-bound connection signs an AUTH event matching
/// that config URL (publicly knowable). Pre-fix: expected = config = A's
/// URL = the signed URL → ACCEPT (cross-host hole). Post-fix: expected
/// derives from `tenant.host() = B` ≠ signed (A) → REJECT.
///
/// This test bites if `nip42_expected_relay_url` is reverted to return
/// `config.relay_url` verbatim — the exact regression the helper guards.
#[test]
fn verify_nip42_rejects_event_signed_for_wrong_communitys_host() {
let challenge = "fixed-challenge-for-test";
// Config URL is A's host (deployment-wide static), and the attacker
// signs an AUTH event with that same URL. Both are knowable to the
// attacker. Connection arrived at community B.
let config_relay_url = "ws://host-a.example:3100";
let signed_relay_url = "ws://host-a.example:3100";
let tenant_b = fresh_tenant("host-b.example:3100");
let expected = nip42_expected_relay_url(config_relay_url, &tenant_b);
let err = verify_nip42_with_urls(challenge, signed_relay_url, &expected).expect_err(
"cross-host NIP-42 AUTH event MUST be rejected — row 44 sibling: \
`relay` URL host must match the per-tenant host, NOT the \
deployment-wide config URL",
);
assert!(
matches!(err, buzz_auth::AuthError::RelayUrlMismatch),
"rejection must carry RelayUrlMismatch (not a generic failure) so \
callers can distinguish it from other auth failures; got {err:?}"
);
}
/// Positive control: a NIP-42 AUTH event signed for host A MUST be
/// accepted on a connection whose tenant resolved to host A. Without
/// this, the cross-host test could be passing vacuously (e.g. if
/// `nip42_expected_relay_url` always produced a URL no event could match).
#[test]
fn verify_nip42_accepts_event_signed_for_matching_host() {
let challenge = "fixed-challenge-for-test";
let signed_relay_url = "ws://host-a.example:3100";
// Configured relay URL deliberately differs in host from the tenant's
// host — proving the helper uses `tenant.host()`, not the config.
let config_relay_url = "ws://other-config-host.example";
let tenant_a = fresh_tenant("host-a.example:3100");
let expected = nip42_expected_relay_url(config_relay_url, &tenant_a);
verify_nip42_with_urls(challenge, signed_relay_url, &expected)
.expect("matching-host NIP-42 AUTH event must verify");
}
/// `nip42_expected_relay_url` derives host from `tenant`, not from
/// `config_relay_url`. Pin both directions: changing the tenant's host
/// changes the output; changing the config's host does NOT.
#[test]
fn nip42_expected_relay_url_uses_tenant_host_not_config_host() {
let tenant_a = fresh_tenant("host-a.example:3100");
let tenant_b = fresh_tenant("host-b.example:3100");
let url_a = nip42_expected_relay_url("ws://config-host.example", &tenant_a);
let url_b = nip42_expected_relay_url("ws://config-host.example", &tenant_b);
assert_eq!(url_a, "ws://host-a.example:3100");
assert_eq!(url_b, "ws://host-b.example:3100");
// Same tenant, two different config hosts → output is identical.
// (If config-host ever leaked into the URL, this assertion would bite —
// catches the exact "reverted to config host" regression.)
let url_a_alt_config = nip42_expected_relay_url("ws://different-config.example", &tenant_a);
assert_eq!(
url_a, url_a_alt_config,
"config-relay-url's host MUST NOT influence the NIP-42 expected URL — \
only its scheme contributes"
);
}
/// `nip42_expected_relay_url` derives scheme from `config_relay_url`'s
/// prefix: `wss://` → `wss`, everything else → `ws`. Deployments that run
/// `ws://` in dev/test must produce a `ws://` URL that matches what
/// tungstenite clients put in the AUTH event's `relay` tag.
#[test]
fn nip42_expected_relay_url_derives_scheme_from_config() {
let tenant = fresh_tenant("host-a.example:3100");
assert_eq!(
nip42_expected_relay_url("wss://config.example", &tenant),
"wss://host-a.example:3100",
"wss:// production config → wss:// URL"
);
assert_eq!(
nip42_expected_relay_url("ws://config.example", &tenant),
"ws://host-a.example:3100",
"ws:// dev config → ws:// URL"
);
}
/// Build a kind:30174 engram envelope authored by `agent`, tagged with `owner`.
fn engram_envelope(agent: &Keys, owner_hex: &str) -> buzz_core::StoredEvent {
let d_tag = Tag::custom(
nostr::TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::D)),
["abcd1234"],
);
let p_tag = Tag::custom(
nostr::TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::P)),
[owner_hex],
);
let ev = EventBuilder::new(Kind::Custom(30174), "engram body")
.tags([d_tag, p_tag])
.sign_with_keys(agent)
.expect("sign engram");
buzz_core::StoredEvent::new(ev, None)
}
/// Regression test for the NIP-AE `/query` search leak (PR #593 review).
///
/// Setup: two engram envelopes by different agents for different owners.
/// An authorized search for `{kinds:[30174], #p:[owner_a]}` would be
/// approved by the engram gate (owner_a is querying engrams addressed to
/// them). The FTS pushdown only carries `kind:=[30174]`, so the
/// envelope for owner_b can come back as a text-match hit. The post-filter
/// in `search_hit_accepted` must reject it.
#[test]
fn search_hit_rejects_envelope_with_mismatched_p_tag() {
let agent_a = Keys::generate();
let agent_b = Keys::generate();
let owner_a = Keys::generate().public_key().to_hex();
let owner_b = Keys::generate().public_key().to_hex();
let env_for_a = engram_envelope(&agent_a, &owner_a);
let env_for_b = engram_envelope(&agent_b, &owner_b);
let p_tag = SingleLetterTag::lowercase(Alphabet::P);
let filter = nostr::Filter::new()
.kind(Kind::Custom(30174))
.custom_tags(p_tag, [&owner_a]);
// 30174 is not owner-gated, so any reader hex is fine here.
let reader = Keys::generate().public_key().to_hex();
assert!(
search_hit_accepted(&filter, &env_for_a, &[], &reader),
"envelope addressed to owner_a must be returned"
);
assert!(
!search_hit_accepted(&filter, &env_for_b, &[], &reader),
"envelope addressed to owner_b must NOT be returned for a #p=[owner_a] search"
);
}
/// `authors=[agent_a]` search must not return an envelope authored by agent_b,
/// even if the FTS text match would otherwise surface it. (The FTS query does
/// carry an `authors` pushdown today, so this is defence-in-depth; mirroring
/// the WS contract.)
#[test]
fn search_hit_rejects_event_with_mismatched_author() {
let agent_a = Keys::generate();
let agent_b = Keys::generate();
let owner = Keys::generate().public_key().to_hex();
let env_a = engram_envelope(&agent_a, &owner);
let env_b = engram_envelope(&agent_b, &owner);
let filter = nostr::Filter::new()
.kind(Kind::Custom(30174))
.author(agent_a.public_key());
let reader = Keys::generate().public_key().to_hex();
assert!(search_hit_accepted(&filter, &env_a, &[], &reader));
assert!(
!search_hit_accepted(&filter, &env_b, &[], &reader),
"authors=[agent_a] search must not return events authored by agent_b"
);
}
/// Channel-scoped events outside the caller's accessible-channel set are
/// rejected by the post-filter regardless of NIP-01 match.
#[test]
fn search_hit_rejects_inaccessible_channel() {
let agent = Keys::generate();
let owner = Keys::generate().public_key().to_hex();
let mut stored = engram_envelope(&agent, &owner);
let scoped_channel = uuid::Uuid::new_v4();
stored.channel_id = Some(scoped_channel);
let p_tag = SingleLetterTag::lowercase(Alphabet::P);
let filter = nostr::Filter::new()
.kind(Kind::Custom(30174))
.custom_tags(p_tag, [&owner]);
let reader = Keys::generate().public_key().to_hex();
assert!(
!search_hit_accepted(&filter, &stored, &[], &reader),
"channel-scoped hit must be rejected when caller has no channel access"
);
assert!(
search_hit_accepted(&filter, &stored, &[scoped_channel], &reader),
"channel-scoped hit must be accepted when caller has access to that channel"
);
}
#[test]
fn extract_buzz_channel_requires_one_string_value() {
assert_eq!(
extract_buzz_channel(&serde_json::json!({"#buzz-channel": ["channel-a"]})),
Some("channel-a")
);
assert_eq!(
extract_buzz_channel(&serde_json::json!({"#buzz-channel": ["channel-a", "channel-b"]})),
None
);
assert_eq!(
extract_buzz_channel(&serde_json::json!({"#buzz-channel": [42]})),
None
);
}
#[test]
fn extract_before_id_valid_hex() {
let hex = "a".repeat(64);
let raw = serde_json::json!({ "before_id": hex });
match extract_before_id(&raw) {
BeforeId::Valid(id) => assert_eq!(id.len(), 32),
_ => panic!("64-char hex must parse as Valid"),
}
}
#[test]
fn extract_before_id_short_hex() {
let raw = serde_json::json!({ "before_id": "a".repeat(63) });
assert!(matches!(extract_before_id(&raw), BeforeId::Malformed));
}
#[test]
fn extract_before_id_long_hex() {
let raw = serde_json::json!({ "before_id": "a".repeat(65) });
assert!(matches!(extract_before_id(&raw), BeforeId::Malformed));
}
#[test]
fn extract_before_id_invalid_hex_chars() {
let raw = serde_json::json!({ "before_id": "z".repeat(64) });
assert!(matches!(extract_before_id(&raw), BeforeId::Malformed));
}
#[test]
fn extract_before_id_absent() {
let raw = serde_json::json!({});
assert!(matches!(extract_before_id(&raw), BeforeId::Absent));
}
#[test]
fn extract_before_id_non_string() {
let raw = serde_json::json!({ "before_id": 12345 });
assert!(matches!(extract_before_id(&raw), BeforeId::Malformed));
}
/// Extension flags opt in only on a literal JSON `true` — absent,
/// non-boolean, and truthy-but-not-bool values all read as false, so a
/// malformed filter degrades to a normal query instead of a wrong window.
#[test]
fn extension_flag_only_true_on_literal_bool() {
assert!(extension_flag(
&serde_json::json!({ "top_level": true }),
"top_level"
));
assert!(!extension_flag(
&serde_json::json!({ "top_level": false }),
"top_level"
));
assert!(!extension_flag(&serde_json::json!({}), "top_level"));
assert!(!extension_flag(
&serde_json::json!({ "top_level": "true" }),
"top_level"
));
assert!(!extension_flag(
&serde_json::json!({ "top_level": 1 }),
"top_level"
));
}
#[test]
fn extract_page_offset_absent_is_none() {
// No `page` → default offset (unrelated general queries untouched).
let raw = serde_json::json!({ "kinds": [0], "limit": 50 });
assert_eq!(extract_page_offset(&raw, Some(50)), None);
}
#[test]
fn extract_page_offset_page_one_is_none() {
// Page 1 is the first page → offset 0, expressed as no override.
let raw = serde_json::json!({ "kinds": [0], "limit": 50, "page": 1 });
assert_eq!(extract_page_offset(&raw, Some(50)), None);
}
#[test]
fn extract_page_offset_computes_offset_from_page_and_limit() {
// Empty people-directory contract: page N → (N-1) * limit.
let raw = serde_json::json!({ "kinds": [0], "limit": 50, "page": 3 });
assert_eq!(extract_page_offset(&raw, Some(50)), Some(100));
}
#[test]
fn extract_page_offset_missing_limit_is_none() {
// Can't size a page without a limit.
let raw = serde_json::json!({ "kinds": [0], "page": 2 });
assert_eq!(extract_page_offset(&raw, None), None);
}
/// Offsets are sized from the *clamped* limit the DB will honor, not from
/// what the client asked for. `filter_to_query_params` clamps an absent or
/// over-ceiling `limit` to `DEFAULT_MAX_PAGE_LIMIT` (guarded in
/// `handlers::req::tests::req_filter_limit_clamps_to_advertised_nip11_max_limit`)
/// and that clamped value is what arrives here — so page N starts exactly
/// N-1 full pages in. Sizing from an unclamped limit would step past rows
/// the previous page never returned.
#[test]
fn extract_page_offset_sizes_pages_from_clamped_limit() {
let clamped = buzz_db::DEFAULT_MAX_PAGE_LIMIT;
assert_eq!(
extract_page_offset(&serde_json::json!({ "page": 2 }), Some(clamped)),
Some(clamped)
);
assert_eq!(
extract_page_offset(&serde_json::json!({ "page": 3 }), Some(clamped)),
Some(clamped * 2)
);
}
#[test]
fn extract_depth_limit_valid() {
let raw = serde_json::json!({ "depth_limit": 3 });
assert_eq!(extract_depth_limit(&raw), Some(3));
}
#[test]
fn extract_thread_cursor_valid() {
// Timestamp-only cursor: 8-byte BE seconds, no tiebreak id.
let raw = serde_json::json!({ "thread_cursor": 1_782_866_946_i64 });
assert_eq!(
extract_thread_cursor(&raw),
Some(1_782_866_946_i64.to_be_bytes().to_vec())
);
}
#[test]
fn extract_thread_cursor_camel_case() {
let raw = serde_json::json!({ "threadCursor": 42_i64 });
assert_eq!(
extract_thread_cursor(&raw),
Some(42_i64.to_be_bytes().to_vec())
);
}
#[test]
fn extract_thread_cursor_composite() {
// Composite cursor: 8-byte BE seconds followed by the raw event-id bytes.
let id_hex = "aa".repeat(32);
let raw = serde_json::json!({
"thread_cursor": 1_782_866_946_i64,
"thread_cursor_id": id_hex,
});
let mut expected = 1_782_866_946_i64.to_be_bytes().to_vec();
expected.extend_from_slice(&[0xaa; 32]);
assert_eq!(extract_thread_cursor(&raw), Some(expected));
}
#[test]
fn extract_thread_cursor_composite_camel_case() {
let id_hex = "bb".repeat(32);
let raw = serde_json::json!({
"threadCursor": 7_i64,
"threadCursorId": id_hex,
});
let mut expected = 7_i64.to_be_bytes().to_vec();
expected.extend_from_slice(&[0xbb; 32]);
assert_eq!(extract_thread_cursor(&raw), Some(expected));
}
#[test]
fn extract_thread_cursor_ignores_bad_id_hex() {
// A malformed id falls back to timestamp-only rather than erroring.
let raw = serde_json::json!({
"thread_cursor": 5_i64,
"thread_cursor_id": "not-hex",
});
assert_eq!(
extract_thread_cursor(&raw),
Some(5_i64.to_be_bytes().to_vec())
);
}
#[test]
fn extract_thread_cursor_absent() {
let raw = serde_json::json!({ "depth_limit": 3 });
assert!(extract_thread_cursor(&raw).is_none());
}
#[test]
fn extract_depth_limit_zero() {
let raw = serde_json::json!({ "depth_limit": 0 });
assert_eq!(extract_depth_limit(&raw), Some(0));
}
#[test]
fn extract_depth_limit_u32_max() {
let raw = serde_json::json!({ "depth_limit": u32::MAX });
assert_eq!(extract_depth_limit(&raw), Some(u32::MAX));
}
#[test]
fn extract_depth_limit_overflow() {
let raw = serde_json::json!({ "depth_limit": (u32::MAX as u64) + 1 });
assert!(extract_depth_limit(&raw).is_none());
}
#[test]
fn extract_depth_limit_negative() {
let raw = serde_json::json!({ "depth_limit": -1 });
assert!(extract_depth_limit(&raw).is_none());
}
#[test]
fn extract_depth_limit_absent() {
let raw = serde_json::json!({});
assert!(extract_depth_limit(&raw).is_none());
}
#[test]
fn extract_depth_limit_float() {
let raw = serde_json::json!({ "depth_limit": 3.5 });
assert!(extract_depth_limit(&raw).is_none());
}
#[test]
fn extract_feed_types_valid() {
let raw = serde_json::json!({ "feed_types": ["mentions", "activity"] });
assert_eq!(
extract_feed_types(&raw),
Some(vec!["mentions".to_string(), "activity".to_string()])
);
}
#[test]
fn extract_feed_types_empty_array() {
let raw = serde_json::json!({ "feed_types": [] });
assert!(extract_feed_types(&raw).is_none());
}
#[test]
fn extract_feed_types_mixed_types() {
let raw = serde_json::json!({ "feed_types": ["mentions", 42, "activity"] });
assert_eq!(
extract_feed_types(&raw),
Some(vec!["mentions".to_string(), "activity".to_string()])
);
}
#[test]
fn extract_feed_types_absent() {
let raw = serde_json::json!({});
assert!(extract_feed_types(&raw).is_none());
}
#[test]
fn extract_feed_types_non_array() {
let raw = serde_json::json!({ "feed_types": "mentions" });
assert!(extract_feed_types(&raw).is_none());
}
#[test]
fn event_accessible_no_channel() {
let keys = Keys::generate();
let ev = EventBuilder::new(Kind::Custom(1), "test")
.sign_with_keys(&keys)
.unwrap();
let se = buzz_core::StoredEvent::new(ev, None);
assert!(event_in_accessible_channel(&se, &[]));
}
#[test]
fn event_accessible_matching_channel() {
let keys = Keys::generate();
let ev = EventBuilder::new(Kind::Custom(1), "test")
.sign_with_keys(&keys)
.unwrap();
let ch = uuid::Uuid::new_v4();
let mut se = buzz_core::StoredEvent::new(ev, None);
se.channel_id = Some(ch);
assert!(event_in_accessible_channel(&se, &[ch]));
}
#[test]
fn event_inaccessible_channel() {
let keys = Keys::generate();
let ev = EventBuilder::new(Kind::Custom(1), "test")
.sign_with_keys(&keys)
.unwrap();
let ch = uuid::Uuid::new_v4();
let other = uuid::Uuid::new_v4();
let mut se = buzz_core::StoredEvent::new(ev, None);
se.channel_id = Some(ch);
assert!(!event_in_accessible_channel(&se, &[other]));
}
/// NIP-DV regression: a relay-signed kind:30622 snapshot must not leak via
/// search through a kindless `ids:[snapshot_id]` filter that carries no #p.
/// `filters_match` passes (id matches), channel check passes (channel_id =
/// None), so only the result-level `reader_authorized_for_event` check
/// stands between a third party and the owner's private hide set.
#[test]
fn search_hit_rejects_dm_visibility_for_kindless_ids_third_party() {
let relay = Keys::generate();
let viewer = Keys::generate().public_key().to_hex();
let third_party = Keys::generate().public_key().to_hex();
let d_tag = Tag::custom(
nostr::TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::D)),
[&viewer],
);
let p_tag = Tag::custom(
nostr::TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::P)),
[&viewer],
);
let ev = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_DM_VISIBILITY as u16), "")
.tags([d_tag, p_tag])
.sign_with_keys(&relay)
.expect("sign snapshot");
let stored = buzz_core::StoredEvent::new(ev.clone(), None);
// Kindless filter — the exact bypass shape: no #p, just the id.
let filter = nostr::Filter::new().id(ev.id);
assert!(
!search_hit_accepted(&filter, &stored, &[], &third_party),
"third party must not receive a DM-visibility snapshot via kindless ids search"
);
assert!(
search_hit_accepted(&filter, &stored, &[], &viewer),
"owner must still receive their own snapshot"
);
}
// ──────────────────────────────────────────────────────────────────────────
// truncate_reason regression tests
//
// Required by T1: prove a near-limit malformed input cannot produce a
// near-limit log payload. No infrastructure needed — pure unit tests.
// ──────────────────────────────────────────────────────────────────────────
/// Near-limit ASCII input (1 MiB) must be capped at exactly `max_bytes`.
#[test]
fn truncate_reason_large_ascii_input_is_bounded() {
let big = "a".repeat(1024 * 1024); // 1 MiB
let result = truncate_reason(&big, 256);
assert_eq!(result.len(), 256);
assert!(result.is_ascii());
}
/// A multi-byte codepoint that straddles the byte boundary must not be
/// split: the output must end at the last complete codepoint before the
/// cap, keeping the slice valid UTF-8.
#[test]
fn truncate_reason_multibyte_codepoint_at_boundary_is_not_split() {
// Build a string of 255 ASCII bytes followed by a 3-byte codepoint (€, U+20AC).
// Naïve cutoff at byte 256 would land inside the 3-byte sequence.
let mut s = "a".repeat(255);
s.push('€'); // 3 bytes: 0xE2 0x82 0xAC — spans bytes 255..258
assert_eq!(s.len(), 258);
let result = truncate_reason(&s, 256);
// Must end before the multi-byte codepoint.
assert_eq!(result.len(), 255);
assert_eq!(result, "a".repeat(255));
assert!(std::str::from_utf8(result.as_bytes()).is_ok());
}
/// Short input well under the cap must be returned unchanged.
#[test]
fn truncate_reason_short_input_returned_unchanged() {
let s = "invalid: kind 24620 rejected";
let result = truncate_reason(s, 256);
assert_eq!(result, s);
}
// ──────────────────────────────────────────────────────────────────────────
// Handler-level tests: submit_event HTTP-counter seam
//
// These tests drive real HTTP requests through the axum router to prove
// that the bridge code path (not just the shared helper) actually
// increments buzz_events_rejected_total{transport="http"}. They are
// discriminating: removing either bridge call site causes the corresponding
// test to fail.
//
// Why `#[ignore = "requires Postgres"]`: submit_event calls bind_community
// (needs a real communities row) and enforce_http_admission (needs Redis).
// Both services run locally in dev. The admission check succeeds for a
// freshly generated pubkey (first request, far below quota), so no Redis
// seeding is required beyond the default local instance.
//
// Why `#[test]` + manual runtime instead of `#[tokio::test]`:
// metrics::with_local_recorder stores the recorder in a thread-local. An
// async test uses a multi-thread scheduler by default; when submit_event
// runs, it may land on a different thread and miss the recorder entirely.
// Using a current_thread runtime with rt.block_on() inside the recorder
// closure guarantees the handler runs on the same thread as the recorder.
// ──────────────────────────────────────────────────────────────────────────
struct AlwaysFreshReplayGuard;
impl Nip98ReplayGuard for AlwaysFreshReplayGuard {
fn try_mark_in_scope<'a>(
&'a self,
_scope: &'a str,
_event_id: &'a nostr::EventId,
_ttl_secs: u64,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<bool, buzz_auth::AuthError>> + Send + 'a>,
> {
Box::pin(async { Ok(true) })
}
}
/// Build an AppState suitable for handler-level bridge tests.
///
/// - `require_auth_token = false` → X-Pubkey dev-mode fallback active.
/// - `require_relay_membership = false` → membership check short-circuits to
/// OpenRelay without a DB lookup.
/// - `nip98_replay` replaced with an always-fresh guard → no Redis needed
/// for replay detection.
/// - Redis pool points at the local dev instance for the admission check.
///
/// Returns `None` when local Postgres is not reachable.
async fn bridge_handler_test_state() -> Option<Arc<crate::state::AppState>> {
let mut config = crate::config::Config::from_env().ok()?;
config.database_url = crate::test_support::database_url();
// Use the real local Redis so enforce_http_admission can pass.
config.redis_url =
std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
config.relay_url = "wss://bridge-test.local".to_string();
config.require_auth_token = false;
config.require_relay_membership = false;
let pool = sqlx::PgPool::connect(&crate::test_support::database_url())
.await
.ok()?;
let db = buzz_db::Db::from_pool(pool.clone());
let redis_pool = deadpool_redis::Config::from_url(&config.redis_url)
.create_pool(Some(deadpool_redis::Runtime::Tokio1))
.ok()?;
let pubsub = Arc::new(
buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone())
.await
.ok()?,
);
let audit = buzz_audit::AuditService::new(pool.clone());
let auth = buzz_auth::AuthService::new(config.auth.clone());
let search = buzz_search::SearchService::new(pool.clone());
let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new(
db.clone(),
buzz_workflow::WorkflowConfig::default(),
));
let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?;
let (mut state, _audit_shutdown) = crate::state::AppState::new(
config,
db,
redis_pool,
audit,
pubsub,
auth,
search,
workflow_engine,
Keys::generate(),
media_storage,
);
state.nip98_replay = Arc::new(AlwaysFreshReplayGuard);
Some(Arc::new(state))
}
/// Drive a single POST /events request through the router and return the
/// HTTP status code.
async fn post_events(
state: Arc<crate::state::AppState>,
host: &str,
pubkey_hex: &str,
body: &[u8],
) -> axum::http::StatusCode {
use axum::body::Body;
use axum::http::{header, Request};
use tower::ServiceExt;
crate::router::build_router(state)
.oneshot(
Request::builder()
.method("POST")
.uri("/events")
.header(header::HOST, host)
.header("x-pubkey", pubkey_hex)
.body(Body::from(body.to_vec()))
.expect("build request"),
)
.await
.expect("router oneshot")
.status()
}
/// Collect buzz_events_rejected_total with (transport, reason) labels from
/// a DebuggingRecorder snapshot.
fn http_reject_counts(
snapshotter: &metrics_util::debugging::Snapshotter,
) -> std::collections::HashMap<(String, String), u64> {
snapshotter
.snapshot()
.into_vec()
.into_iter()
.filter(|(key, ..)| key.key().name() == "buzz_events_rejected_total")
.map(|(key, _, _, value)| {
let metrics_util::debugging::DebugValue::Counter(n) = value else {
panic!("buzz_events_rejected_total must be a counter");
};
let labels: Vec<_> = key.key().labels().collect();
let transport = labels
.iter()
.find(|l| l.key() == "transport")
.map(|l| l.value().to_owned())
.unwrap_or_default();
let reason = labels
.iter()
.find(|l| l.key() == "reason")
.map(|l| l.value().to_owned())
.unwrap_or_default();
((transport, reason), n)
})
.collect()
}
/// T2a — pre-parse 400 arm: a POST /events with an invalid JSON body must
/// increment buzz_events_rejected_total{transport="http",reason="invalid"}.
///
/// Discriminating: if the `reject_with_transport` call in bridge.rs's
/// `serde_json::from_slice` map_err closure is removed, this test fails.
#[test]
#[ignore = "requires Postgres"]
fn submit_event_invalid_json_body_increments_http_transport_counter() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("current_thread runtime");
let Some(state) = rt.block_on(bridge_handler_test_state()) else {
panic!("local Postgres not reachable — start Postgres on 127.0.0.1:5432 before running ignored bridge handler tests");
};
// Provision a fresh community so bind_community succeeds.
let host = {
let h = format!("bridge-test-{}.local", uuid::Uuid::new_v4().simple());
rt.block_on(state.db.ensure_configured_community(&h))
.expect("ensure community");
h
};
let pubkey_hex = Keys::generate().public_key().to_hex();
let recorder = metrics_util::debugging::DebuggingRecorder::new();
let snapshotter = recorder.snapshotter();
metrics::with_local_recorder(&recorder, || {
let status = rt.block_on(post_events(
state.clone(),
&host,
&pubkey_hex,
b"not valid json at all",
));
assert_eq!(
status,
axum::http::StatusCode::BAD_REQUEST,
"malformed body must yield 400"
);
});
let counts = http_reject_counts(&snapshotter);
assert_eq!(
counts.get(&("http".to_owned(), "invalid".to_owned())),
Some(&1),
"pre-parse 400 arm must increment transport=http,reason=invalid"
);
}
/// T2b — post-parse IngestError::Rejected arm: a POST /events with a
/// valid but relay-only-kind event (kind 13534 = membership snapshot) must
/// increment buzz_events_rejected_total{transport="http",reason="invalid"}.
///
/// Kind 13534 is rejected in ingest_event before signature verification,
/// so any properly signed Nostr event of this kind triggers the arm.
///
/// Discriminating: if the `reject_with_transport` call in bridge.rs's
/// IngestError::Rejected match arm is removed, this test fails.
#[test]
#[ignore = "requires Postgres"]
fn submit_event_relay_only_kind_increments_http_transport_counter() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("current_thread runtime");
let Some(state) = rt.block_on(bridge_handler_test_state()) else {
panic!("local Postgres not reachable — start Postgres on 127.0.0.1:5432 before running ignored bridge handler tests");
};
let host = {
let h = format!("bridge-test-{}.local", uuid::Uuid::new_v4().simple());
rt.block_on(state.db.ensure_configured_community(&h))
.expect("ensure community");
h
};
let client_keys = Keys::generate();
let pubkey_hex = client_keys.public_key().to_hex();
// Kind 13534 (membership snapshot) is relay-only; ingest_event rejects
// it before reaching signature verification.
let relay_only_event = EventBuilder::new(
Kind::Custom(buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as u16),
"",
)
.sign_with_keys(&client_keys)
.expect("sign relay-only event");
let event_json = serde_json::to_vec(&relay_only_event).expect("serialize event");
let recorder = metrics_util::debugging::DebuggingRecorder::new();
let snapshotter = recorder.snapshotter();
metrics::with_local_recorder(&recorder, || {
let status = rt.block_on(post_events(state.clone(), &host, &pubkey_hex, &event_json));
assert_eq!(
status,
axum::http::StatusCode::BAD_REQUEST,
"relay-only-kind event must yield 400"
);
});
let counts = http_reject_counts(&snapshotter);
assert_eq!(
counts.get(&("http".to_owned(), "invalid".to_owned())),
Some(&1),
"IngestError::Rejected arm must increment transport=http,reason=invalid"
);
}
// ──────────────────────────────────────────────────────────────────────────
// Log-capture helpers and attribution-invariant tests
//
// These tests assert that exactly ONE "HTTP bridge request" log line
// appears per authed request, pinning the W1 single-terminal-log invariant
// and the E1 no-double-log rule.
//
// Infrastructure: same `#[ignore = "requires Postgres"]` + current_thread
// runtime discipline as the HTTP-counter tests above.
// ──────────────────────────────────────────────────────────────────────────
/// Shared buffer that collects all bytes written by the tracing fmt layer.
#[derive(Clone)]
struct CapturingMakeWriter {
buf: Arc<Mutex<Vec<u8>>>,
}
struct CapturingWriter {
buf: Arc<Mutex<Vec<u8>>>,
}
impl std::io::Write for CapturingWriter {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
self.buf.lock().unwrap().extend_from_slice(data);
Ok(data.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturingMakeWriter {
type Writer = CapturingWriter;
fn make_writer(&'a self) -> Self::Writer {
CapturingWriter {
buf: Arc::clone(&self.buf),
}
}
}
/// Run `post_events` with a capturing subscriber and return `(status_code, captured_log)`.
fn run_and_capture(
rt: &tokio::runtime::Runtime,
state: Arc<crate::state::AppState>,
host: &str,
pubkey_hex: &str,
body: &[u8],
) -> (axum::http::StatusCode, String) {
let buf = Arc::new(Mutex::new(Vec::<u8>::new()));
let make_writer = CapturingMakeWriter {
buf: Arc::clone(&buf),
};
let subscriber = tracing_subscriber::fmt()
.with_writer(make_writer)
.with_ansi(false)
.finish();
let status = tracing::subscriber::with_default(subscriber, || {
rt.block_on(post_events(state, host, pubkey_hex, body))
});
let captured = String::from_utf8(buf.lock().unwrap().clone()).unwrap_or_default();
(status, captured)
}
/// Count lines that contain the terminal attribution marker.
fn count_attribution_lines(log: &str) -> usize {
log.lines()
.filter(|l| l.contains("HTTP bridge request"))
.count()
}
/// T3a — exactly-once invariant, pre-parse 400 arm (invalid JSON).
///
/// An authenticated client that submits a non-JSON body must produce
/// exactly ONE "HTTP bridge request" log line. This pins W1 (early exits
/// are attributed) and E1 (no double line even for the parse-fail arm).
///
/// Discriminating: if the attribution log is removed from the ParseFail
/// arm in submit_event, this test fails.
#[test]
#[ignore = "requires Postgres"]
fn submit_event_invalid_json_emits_exactly_one_attribution_line() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("current_thread runtime");
let state = rt
.block_on(bridge_handler_test_state())
.expect("local Postgres not reachable — start Postgres on 127.0.0.1:5432 before running ignored bridge handler tests");
let host = {
let h = format!("bridge-attr-{}.local", uuid::Uuid::new_v4().simple());
rt.block_on(state.db.ensure_configured_community(&h))
.expect("ensure community");
h
};
let pubkey_hex = Keys::generate().public_key().to_hex();
let recorder = metrics_util::debugging::DebuggingRecorder::new();
let (status, log) = metrics::with_local_recorder(&recorder, || {
run_and_capture(&rt, state, &host, &pubkey_hex, b"not valid json at all")
});
assert_eq!(
status,
axum::http::StatusCode::BAD_REQUEST,
"invalid JSON must yield 400"
);
let n = count_attribution_lines(&log);
assert_eq!(
n, 1,
"expected exactly 1 attribution line for invalid-JSON arm, got {n};\nlog:\n{log}"
);
assert!(
log.contains(&pubkey_hex[..16]),
"attribution line must carry the pubkey;\nlog:\n{log}"
);
}
/// T3b — exactly-once invariant, post-parse IngestError::Rejected arm (relay-only kind).
///
/// An authenticated client that submits a relay-only-kind event (kind 13534)
/// must produce exactly ONE "HTTP bridge request" log line — not two
/// (the old code emitted both an info! attribution and a warn! reason line).
///
/// Discriminating: if two log lines are emitted (old double-log bug), this
/// test fails.
#[test]
#[ignore = "requires Postgres"]
fn submit_event_relay_only_kind_emits_exactly_one_attribution_line() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("current_thread runtime");
let state = rt
.block_on(bridge_handler_test_state())
.expect("local Postgres not reachable — start Postgres on 127.0.0.1:5432 before running ignored bridge handler tests");
let host = {
let h = format!("bridge-attr-{}.local", uuid::Uuid::new_v4().simple());
rt.block_on(state.db.ensure_configured_community(&h))
.expect("ensure community");
h
};
let client_keys = Keys::generate();
let pubkey_hex = client_keys.public_key().to_hex();
let relay_only_event = EventBuilder::new(
Kind::Custom(buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as u16),
"",
)
.sign_with_keys(&client_keys)
.expect("sign relay-only event");
let event_json = serde_json::to_vec(&relay_only_event).expect("serialize event");
let recorder = metrics_util::debugging::DebuggingRecorder::new();
let (status, log) = metrics::with_local_recorder(&recorder, || {
run_and_capture(&rt, state, &host, &pubkey_hex, &event_json)
});
assert_eq!(
status,
axum::http::StatusCode::BAD_REQUEST,
"relay-only-kind event must yield 400"
);
let n = count_attribution_lines(&log);
assert_eq!(
n, 1,
"expected exactly 1 attribution line for IngestError::Rejected arm, got {n};\nlog:\n{log}"
);
assert!(
log.contains(&pubkey_hex[..16]),
"attribution line must carry the pubkey;\nlog:\n{log}"
);
}
}