Skip to content

Commit 2fd6f07

Browse files
committed
fix(relay): preserve authenticated existing private reads
Signed-off-by: Logan Johnson <loganj@squareup.com>
1 parent 01bacb8 commit 2fd6f07

4 files changed

Lines changed: 385 additions & 5 deletions

File tree

crates/buzz-db/src/store/event.rs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ use sqlx::{PgConnection, PgPool, Postgres, QueryBuilder, Row, Transaction};
1010
use uuid::Uuid;
1111

1212
use buzz_core::kind::{
13-
event_kind_i32, is_ephemeral, is_parameterized_replaceable, KIND_AUTH, KIND_EVENT_REMINDER,
14-
KIND_HUDDLE_STARTED, SHARED_GATED_KINDS,
13+
event_kind_i32, is_ephemeral, is_parameterized_replaceable, AUTHOR_ONLY_KINDS, KIND_AUTH,
14+
KIND_EVENT_REMINDER, KIND_HUDDLE_STARTED, SHARED_GATED_KINDS,
1515
};
1616
use buzz_core::{CommunityId, StoredEvent};
1717
use buzz_datastore_tracing::datastore_span;
@@ -113,6 +113,10 @@ pub struct EventQuery {
113113
/// SQL pushdown is sound. Keeping `event_visible_to_reader` as post-filter
114114
/// defense-in-depth catches any residual mismatch.
115115
pub shared_gated_reader: Option<Vec<u8>>,
116+
/// Author-only visibility reader for [`query_events`]: exclude foreign
117+
/// [`AUTHOR_ONLY_KINDS`] before ordering, offset and limit. This does not
118+
/// affect `count_events`; callers must retain its existing fallback gate.
119+
pub author_only_reader: Option<Vec<u8>>,
116120
}
117121

118122
impl EventQuery {
@@ -144,6 +148,7 @@ impl EventQuery {
144148
channel_ids_include_global: true,
145149
max_limit: None,
146150
shared_gated_reader: None,
151+
author_only_reader: None,
147152
}
148153
}
149154
}
@@ -682,6 +687,19 @@ pub(crate) async fn query_events_on(
682687
qb.push(")");
683688
}
684689

690+
// Author-only visibility belongs before pagination, just like shared-gated
691+
// visibility. Keep relay result checks as defense in depth.
692+
if let Some(ref reader_bytes) = q.author_only_reader {
693+
qb.push(format!(" AND ({col_prefix}kind NOT IN ("));
694+
let mut sep = qb.separated(", ");
695+
for kind in AUTHOR_ONLY_KINDS {
696+
sep.push_bind(*kind as i32);
697+
}
698+
qb.push(format!(") OR {col_prefix}pubkey = "));
699+
qb.push_bind(reader_bytes.clone());
700+
qb.push(")");
701+
}
702+
685703
// Composite ordering for deterministic pagination across ALL callers of
686704
// query_events (WebSocket REQ, REST endpoints, canvas, notes, etc.).
687705
// The `id ASC` tiebreaker ensures stable results when events share the

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

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,26 @@ pub(crate) fn verify_bridge_auth_with_options(
146146
Err(api_error(StatusCode::UNAUTHORIZED, "missing Nostr auth"))
147147
}
148148

149+
/// A declared dev-mode identity is not proof of authorship. Preserve the
150+
/// public-kind fallback, but require verified NIP-98 for any filter that could
151+
/// read author-only data, including mixed-kind and known-ID queries.
152+
fn authorize_author_only_read(
153+
filters: &[nostr::Filter],
154+
signed_auth_created_at: Option<u64>,
155+
) -> Result<(), (StatusCode, Json<Value>)> {
156+
if signed_auth_created_at.is_none()
157+
&& filters
158+
.iter()
159+
.any(crate::handlers::req::filter_can_match_author_only_kinds)
160+
{
161+
return Err(api_error(
162+
StatusCode::UNAUTHORIZED,
163+
"auth-required: author-only reads require NIP-98 authentication",
164+
));
165+
}
166+
Ok(())
167+
}
168+
149169
/// Check NIP-98 replay and record the event ID atomically.
150170
///
151171
/// The correctness boundary is the shared, community-scoped Redis seen-set on
@@ -1121,6 +1141,8 @@ async fn query_events_authed(
11211141
crate::handlers::req::extract_channel_ids_from_filters_limited(&filters)
11221142
.map_err(|()| api_error(StatusCode::BAD_REQUEST, "too many explicit channels"))?;
11231143

1144+
authorize_author_only_read(&filters, signed_auth_created_at)?;
1145+
11241146
// P-gated kinds (gift wraps, member notifications, observer frames) require
11251147
// the caller's own pubkey in the #p tag — same enforcement as WS REQ handler.
11261148
let authed_pubkey_hex = pubkey.to_hex();
@@ -1655,6 +1677,8 @@ async fn count_events_authed(
16551677
crate::handlers::req::extract_channel_ids_from_filters_limited(&filters)
16561678
.map_err(|()| api_error(StatusCode::BAD_REQUEST, "too many explicit channels"))?;
16571679

1680+
authorize_author_only_read(&filters, signed_auth_created_at)?;
1681+
16581682
// P-gated kinds enforcement — same as WS REQ and /query.
16591683
let authed_pubkey_hex = pubkey.to_hex();
16601684
if !crate::handlers::req::p_gated_filters_authorized(&filters, &authed_pubkey_hex) {
@@ -2529,6 +2553,10 @@ fn ban_json(b: &buzz_db::moderation::BanRecord) -> Value {
25292553
})
25302554
}
25312555

2556+
#[cfg(test)]
2557+
#[path = "private_read_postgres_tests.rs"]
2558+
mod private_read_postgres_tests;
2559+
25322560
#[cfg(test)]
25332561
mod postgres_tests {
25342562
use super::*;
@@ -3839,7 +3867,7 @@ mod postgres_tests {
38393867
/// - Redis pool points at the local dev instance for the admission check.
38403868
///
38413869
/// Returns `None` when local Postgres is not reachable.
3842-
async fn bridge_handler_test_state() -> Option<Arc<crate::state::AppState>> {
3870+
pub(super) async fn bridge_handler_test_state() -> Option<Arc<crate::state::AppState>> {
38433871
let mut config = crate::config::Config::from_env().ok()?;
38443872
config.database_url = crate::test_support::database_url();
38453873
// Use the real local Redis so enforce_http_admission can pass.

0 commit comments

Comments
 (0)