Files
buzz/crates/buzz-relay/src/api/bridge.rs
Tyler 14fba21e57 Multi-tenant Buzz relay: community_id as a server-resolved key (comprehensive rewrite) (#1321)
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Quinn <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: Sami <sami@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
2026-06-29 12:39:02 -04:00

1974 lines
75 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, State},
http::{HeaderMap, StatusCode},
response::Json,
};
use base64::Engine;
use serde_json::Value;
use buzz_auth::{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};
/// Verify bridge auth: NIP-98 (production) or X-Pubkey (dev mode).
///
/// Returns the authenticated public key and an event ID for replay detection.
/// For X-Pubkey dev mode, the event ID is a zero hash (no replay concern).
fn verify_bridge_auth(
headers: &HeaderMap,
method: &str,
url: &str,
body: Option<&[u8]>,
require_auth_token: bool,
) -> Result<(nostr::PublicKey, [u8; 32]), (StatusCode, Json<Value>)> {
// 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();
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((pubkey, event_id_bytes));
}
// 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((pubkey, [0u8; 32]));
}
}
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.
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.
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;
fn extract_before_id(raw: &Value) -> Option<Vec<u8>> {
let hex_str = raw.get("before_id")?.as_str()?;
if hex_str.len() == 64 {
hex::decode(hex_str).ok()
} else {
None
}
}
fn extract_depth_limit(raw: &Value) -> Option<u32> {
raw.get("depth_limit")?
.as_u64()
.and_then(|n| u32::try_from(n).ok())
}
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 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,
}
}
/// 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 (pubkey, event_id_bytes) = verify_bridge_auth(
&headers,
"POST",
&url,
Some(&body),
state.config.require_auth_token,
)?;
check_nip98_replay(&state, &tenant, event_id_bytes).await?;
let pubkey_bytes = pubkey.to_bytes().to_vec();
// Enforce relay membership (with NIP-OA fallback via x-auth-tag header).
let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok());
super::relay_members::enforce_relay_membership(
&state,
tenant.community(),
&pubkey_bytes,
auth_tag,
)
.await?;
let event: nostr::Event = serde_json::from_slice(&body)
.map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid event JSON: {e}")))?;
// Mesh signaling kinds (24620 status report, 24621 connect request) are
// ephemeral and deliberately absent from ingest_event's per-kind allowlist.
// The desktop's Rust coordinator publishes them via this bridge, so route
// them to the mesh handlers — the HTTP twin of the WS door's special-casing
// in handlers::event. Membership was enforced above; the handlers re-check
// it fail-closed.
let kind_u32 = buzz_core::kind::event_kind_u32(&event);
if kind_u32 == buzz_core::kind::KIND_MESH_STATUS_REPORT
|| kind_u32 == buzz_core::kind::KIND_MESH_CONNECT_REQUEST
{
let event_id = event.id.to_hex();
return match crate::handlers::mesh_signaling::handle_mesh_event_http(
&state, &tenant, &pubkey, &event,
)
.await
{
Ok(()) => Ok(Json(serde_json::json!({
"event_id": event_id,
"accepted": true,
"message": "",
}))),
Err(msg) => Err(api_error(StatusCode::BAD_REQUEST, &msg)),
};
}
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) => Ok(Json(serde_json::json!({
"event_id": result.event_id,
"accepted": result.accepted,
"message": result.message,
}))),
Err(e) => match e {
IngestError::Rejected(msg) => Err(api_error(StatusCode::BAD_REQUEST, &msg)),
IngestError::AuthFailed(msg) => Err(api_error(StatusCode::FORBIDDEN, &msg)),
IngestError::Internal(msg) => Err(internal_error(&msg)),
},
}
}
/// 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 (pubkey, event_id_bytes) = verify_bridge_auth(
&headers,
"POST",
&url,
Some(&body),
state.config.require_auth_token,
)?;
check_nip98_replay(&state, &tenant, event_id_bytes).await?;
let pubkey_bytes = pubkey.to_bytes().to_vec();
let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok());
super::relay_members::enforce_relay_membership(
&state,
tenant.community(),
&pubkey_bytes,
auth_tag,
)
.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}")))?;
// 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 accessible_channels = state
.get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes)
.await
.map_err(|e| internal_error(&format!("channel access lookup: {e}")))?;
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_events) = synthesize_presence(&state, &tenant, &filters).await {
return Ok(Json(Value::Array(presence_events)));
}
let mut events: Vec<Value> = Vec::new();
let mut handled: std::collections::HashSet<usize> = std::collections::HashSet::new();
for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() {
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(
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(
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(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;
}
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_replies = state
.db
.get_thread_replies(tenant.community(), &root_bytes, Some(depth), limit, None)
.await
.map_err(|e| internal_error(&format!("thread query error: {e}")))?;
for reply in thread_replies {
let se = reply.stored_event;
if !event_in_accessible_channel(&se, &accessible_channels) {
continue;
}
if let Ok(v) = serde_json::to_value(&se.event) {
events.push(v);
}
}
handled.insert(idx);
}
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;
if let Some(bid) = extract_before_id(raw) {
if query.until.is_none() {
return Err(api_error(
StatusCode::BAD_REQUEST,
"before_id requires until to be set",
));
}
query.before_id = Some(bid);
}
match state.db.query_events(&query).await {
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`.
if !buzz_core::filter::reader_authorized_for_event(
&se.event,
&authed_pubkey_hex,
) {
continue;
}
if crate::handlers::req::is_author_only_event(&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)))
}
/// 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 (pubkey, event_id_bytes) = verify_bridge_auth(
&headers,
"POST",
&url,
Some(&body),
state.config.require_auth_token,
)?;
check_nip98_replay(&state, &tenant, event_id_bytes).await?;
let pubkey_bytes = pubkey.to_bytes().to_vec();
let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok());
super::relay_members::enforce_relay_membership(
&state,
tenant.community(),
&pubkey_bytes,
auth_tag,
)
.await?;
let filters: Vec<nostr::Filter> = serde_json::from_slice(&body)
.map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid filters: {e}")))?;
// 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 accessible_channels = state
.get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes)
.await
.map_err(|e| internal_error(&format!("channel access lookup: {e}")))?;
let mut total: u64 = 0;
for filter in &filters {
let needs_author_only_filtering =
crate::handlers::req::filter_can_match_author_only_kinds(filter);
// If filter targets a specific channel, verify access.
if let Some(ch_id) = extract_channel_from_filter(filter) {
if !accessible_channels.contains(&ch_id) {
continue; // Skip filters targeting inaccessible channels.
}
// Channel is accessible — count with pushability check.
let query = crate::handlers::req::build_event_query_from_filter(
filter,
&pubkey_bytes,
&state,
tenant.community(),
)
.await;
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)
{
match state.db.count_events(&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;
q.limit = Some(100_000);
q.max_limit = Some(100_000);
match state.db.query_events(&q).await {
Ok(stored_events) => {
for se in stored_events {
if !buzz_core::filter::filters_match(std::slice::from_ref(filter), &se)
{
continue;
}
if crate::handlers::req::is_author_only_event(&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());
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)
{
query.limit = None;
match state.db.count_events(&query).await {
Ok(n) => total += n as u64,
Err(e) => {
return Err(internal_error(&format!("count error: {e}")));
}
}
} else {
// Fallback: query with high limit + post-filter for correctness.
query.limit = Some(100_000);
query.max_limit = Some(100_000);
match state.db.query_events(&query).await {
Ok(stored_events) => {
for se in stored_events {
if !buzz_core::filter::filters_match(std::slice::from_ref(filter), &se)
{
continue;
}
if crate::handlers::req::is_author_only_event(&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. Returns first page of results (no pagination for bridge MVP).
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_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: 1,
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(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;
}
if crate::handlers::req::is_author_only_event(&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();
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(&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 `Some(events)` if handled, `None` to fall through to normal query.
async fn synthesize_presence(
state: &AppState,
tenant: &buzz_core::tenant::TenantContext,
filters: &[nostr::Filter],
) -> Option<Vec<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(Vec::new());
}
// Dedup pubkeys.
all_pubkeys.sort_by_key(|pk| pk.to_hex());
all_pubkeys.dedup();
// Look up Redis.
let presence_map = state
.pubsub
.get_presence_bulk(tenant, &all_pubkeys)
.await
.unwrap_or_default();
if presence_map.is_empty() {
return Some(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.
let tags = vec![nostr::Tag::parse(["p", pubkey_hex]).ok()?];
let event =
nostr::EventBuilder::new(nostr::Kind::Custom(KIND_PRESENCE_UPDATE as u16), status)
.tags(tags)
.custom_created_at(nostr::Timestamp::from(now))
.sign_with_keys(&state.relay_keypair)
.ok()?;
if let Ok(v) = serde_json::to_value(&event) {
events.push(v);
}
}
Some(events)
}
#[cfg(test)]
mod tests {
use super::*;
use nostr::{Alphabet, EventBuilder, Keys, Kind, SingleLetterTag, Tag};
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));
}
#[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.
#[tokio::test]
#[ignore = "requires Redis"]
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.
#[tokio::test]
#[ignore = "requires Redis"]
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);
}
/// 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<'a>(
&'a self,
_ctx: &'a buzz_core::TenantContext,
_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:?}"
);
}
/// 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 (pubkey, _event_id_bytes) =
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"
);
}
/// `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_before_id_valid_hex() {
let hex = "a".repeat(64);
let raw = serde_json::json!({ "before_id": hex });
let result = extract_before_id(&raw);
assert!(result.is_some());
assert_eq!(result.unwrap().len(), 32);
}
#[test]
fn extract_before_id_short_hex() {
let raw = serde_json::json!({ "before_id": "a".repeat(63) });
assert!(extract_before_id(&raw).is_none());
}
#[test]
fn extract_before_id_long_hex() {
let raw = serde_json::json!({ "before_id": "a".repeat(65) });
assert!(extract_before_id(&raw).is_none());
}
#[test]
fn extract_before_id_invalid_hex_chars() {
let raw = serde_json::json!({ "before_id": "z".repeat(64) });
assert!(extract_before_id(&raw).is_none());
}
#[test]
fn extract_before_id_absent() {
let raw = serde_json::json!({});
assert!(extract_before_id(&raw).is_none());
}
#[test]
fn extract_before_id_non_string() {
let raw = serde_json::json!({ "before_id": 12345 });
assert!(extract_before_id(&raw).is_none());
}
#[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_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"
);
}
}