Skip to content

Commit 0a1da5c

Browse files
author
npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je
committed
fix(workflows): scope overview in database
Resolve the Desktop workflow overview against the authenticated user's active channel memberships in PostgreSQL, with bounded keyset pagination. Remove the multi-channel request array and SQL pushdown path while preserving exact single-channel reads. Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
1 parent 239d6be commit 0a1da5c

9 files changed

Lines changed: 212 additions & 198 deletions

File tree

crates/buzz-db/src/event.rs

Lines changed: 68 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,10 @@ pub struct EventQuery {
7474
/// channel-less global events. Applied before SQL `LIMIT` so access-filtered
7575
/// historical pages have exact exhaustion semantics.
7676
pub channel_ids: Option<Vec<uuid::Uuid>>,
77-
/// Restrict results to events in exactly these channels. Unlike
78-
/// `channel_ids`, this excludes channel-less global events. Used for
79-
/// multi-value `#h` filters so unrelated accessible channels and global
80-
/// events cannot consume the SQL `LIMIT` before NIP-01 post-filtering.
81-
pub exact_channel_ids: Option<Vec<uuid::Uuid>>,
77+
/// Restrict results to events in channels where this pubkey has an active
78+
/// membership. The membership is resolved by PostgreSQL with an indexed
79+
/// `EXISTS` subquery, avoiding client-supplied channel-ID arrays.
80+
pub member_channel_pubkey: Option<Vec<u8>>,
8281
/// Override the default page clamp ([`DEFAULT_MAX_PAGE_LIMIT`]). Used by
8382
/// the COUNT fallback path, which needs to fetch all matching events for
8483
/// post-filter counting. When None, the default clamp applies.
@@ -127,7 +126,7 @@ impl EventQuery {
127126
ids: None,
128127
e_tags: None,
129128
channel_ids: None,
130-
exact_channel_ids: None,
129+
member_channel_pubkey: None,
131130
max_limit: None,
132131
shared_gated_reader: None,
133132
}
@@ -410,7 +409,7 @@ pub(crate) async fn query_events_on(
410409
qb.push(format!(" AND {col_prefix}channel_id IS NULL"));
411410
}
412411

413-
// Multi-channel array pushdown: restrict to events in any of these channels
412+
// Multi-channel IN pushdown: restrict to events in any of these channels
414413
// OR global events (channel_id IS NULL). Used by NIP-45 COUNT to enforce
415414
// channel access at the SQL level without fetching all rows.
416415
//
@@ -422,23 +421,41 @@ pub(crate) async fn query_events_on(
422421
qb.push(format!(" AND {col_prefix}channel_id IS NULL"));
423422
} else {
424423
qb.push(format!(
425-
" AND ({col_prefix}channel_id IS NULL OR {col_prefix}channel_id = ANY("
424+
" AND ({col_prefix}channel_id IS NULL OR {col_prefix}channel_id IN ("
426425
));
427-
qb.push_bind(ch_ids.clone());
426+
let mut sep = qb.separated(", ");
427+
for ch in ch_ids {
428+
sep.push_bind(*ch);
429+
}
428430
qb.push("))");
429431
}
430432
}
431433

432-
// Exact multi-channel pushdown for NIP-01 `#h` filters. Unlike the access
433-
// scope above, an `#h` filter never matches channel-less global events.
434-
if let Some(ref ch_ids) = q.exact_channel_ids {
435-
if ch_ids.is_empty() {
436-
qb.push(" AND FALSE");
434+
// Membership-scoped overview queries resolve channel access inside
435+
// PostgreSQL. The active-membership index starts with (community_id,
436+
// pubkey), and the channel PK check completes the lookup without shipping
437+
// every channel UUID through the client and relay.
438+
if let Some(ref member_pubkey) = q.member_channel_pubkey {
439+
let outer_event_prefix = if q.p_tag_hex.is_some() {
440+
"e."
437441
} else {
438-
qb.push(format!(" AND {col_prefix}channel_id = ANY("));
439-
qb.push_bind(ch_ids.clone());
440-
qb.push(")");
441-
}
442+
"events."
443+
};
444+
qb.push(format!(
445+
" AND {outer_event_prefix}channel_id IS NOT NULL AND EXISTS (\
446+
SELECT 1 FROM channel_members cm \
447+
JOIN channels c \
448+
ON c.community_id = cm.community_id AND c.id = cm.channel_id \
449+
WHERE cm.community_id = {outer_event_prefix}community_id \
450+
AND cm.channel_id = {outer_event_prefix}channel_id \
451+
AND cm.pubkey = "
452+
));
453+
qb.push_bind(member_pubkey.clone());
454+
qb.push(
455+
" AND cm.removed_at IS NULL \
456+
AND c.deleted_at IS NULL \
457+
AND (c.channel_type != 'dm' OR cm.hidden_at IS NULL))",
458+
);
442459
}
443460

444461
if let Some(ks) = q.kinds.as_deref().filter(|k| !k.is_empty()) {
@@ -685,32 +702,23 @@ pub(crate) async fn count_events_on(conn: &mut sqlx::PgConnection, q: &EventQuer
685702
qb.push(format!(" AND {col_prefix}channel_id IS NULL"));
686703
}
687704

688-
// Multi-channel array pushdown for COUNT: restrict to accessible channels + global.
705+
// Multi-channel IN pushdown for COUNT: restrict to accessible channels + global.
689706
// SECURITY: Some(empty vec) = no channel access → global events only.
690707
if let Some(ref ch_ids) = q.channel_ids {
691708
if ch_ids.is_empty() {
692709
qb.push(format!(" AND {col_prefix}channel_id IS NULL"));
693710
} else {
694711
qb.push(format!(
695-
" AND ({col_prefix}channel_id IS NULL OR {col_prefix}channel_id = ANY("
712+
" AND ({col_prefix}channel_id IS NULL OR {col_prefix}channel_id IN ("
696713
));
697-
qb.push_bind(ch_ids.clone());
714+
let mut sep = qb.separated(", ");
715+
for ch in ch_ids {
716+
sep.push_bind(*ch);
717+
}
698718
qb.push("))");
699719
}
700720
}
701721

702-
// Exact multi-channel pushdown for NIP-01 `#h` filters. Unlike the access
703-
// scope above, an `#h` filter never matches channel-less global events.
704-
if let Some(ref ch_ids) = q.exact_channel_ids {
705-
if ch_ids.is_empty() {
706-
qb.push(" AND FALSE");
707-
} else {
708-
qb.push(format!(" AND {col_prefix}channel_id = ANY("));
709-
qb.push_bind(ch_ids.clone());
710-
qb.push(")");
711-
}
712-
}
713-
714722
if let Some(ks) = q.kinds.as_deref().filter(|k| !k.is_empty()) {
715723
qb.push(format!(" AND {col_prefix}kind IN ("));
716724
let mut sep = qb.separated(", ");
@@ -1951,80 +1959,53 @@ mod tests {
19511959

19521960
#[tokio::test]
19531961
#[ignore = "requires Postgres"]
1954-
async fn exact_channel_scope_is_applied_before_limit_and_excludes_globals() {
1962+
async fn member_channel_scope_is_applied_before_limit_and_excludes_non_members() {
19551963
let pool = setup_pool().await;
19561964
let community_uuid = make_test_community(&pool).await;
19571965
let community = CommunityId::from_uuid(community_uuid);
1958-
let requested_a = make_test_channel(&pool, community_uuid, None).await;
1959-
let requested_b = make_test_channel(&pool, community_uuid, None).await;
1960-
let unrequested = make_test_channel(&pool, community_uuid, None).await;
1961-
let base = 1_800_100_000;
1966+
let member_channel = make_test_channel(&pool, community_uuid, None).await;
1967+
let nonmember_channel = make_test_channel(&pool, community_uuid, None).await;
1968+
let member_pubkey = vec![42_u8; 32];
1969+
sqlx::query(
1970+
"INSERT INTO channel_members (community_id, channel_id, pubkey) VALUES ($1, $2, $3)",
1971+
)
1972+
.bind(community_uuid)
1973+
.bind(member_channel)
1974+
.bind(&member_pubkey)
1975+
.execute(&pool)
1976+
.await
1977+
.expect("insert active membership");
19621978

1963-
// Newer rows outside the exact #h set must not consume the SQL page.
1979+
let base = 1_800_100_000;
19641980
for offset in 10..13 {
1965-
let event = make_event_at(39_001, "newer unrequested", base + offset);
1966-
insert_event(&pool, community, &event, Some(unrequested))
1981+
let event = make_event_at(30_620, "newer nonmember", base + offset);
1982+
insert_event(&pool, community, &event, Some(nonmember_channel))
19671983
.await
1968-
.expect("insert unrequested candidate");
1984+
.expect("insert nonmember workflow");
19691985
}
1970-
let global = make_event_at(39_001, "newer global", base + 9);
1986+
let global = make_event_at(30_620, "newer global", base + 9);
19711987
insert_event(&pool, community, &global, None)
19721988
.await
1973-
.expect("insert global candidate");
1974-
let requested_newer = make_event_at(39_001, "requested a", base + 2);
1975-
insert_event(&pool, community, &requested_newer, Some(requested_a))
1989+
.expect("insert global workflow");
1990+
let member_workflow = make_event_at(30_620, "older member", base + 1);
1991+
insert_event(&pool, community, &member_workflow, Some(member_channel))
19761992
.await
1977-
.expect("insert requested a candidate");
1978-
let requested_older = make_event_at(39_001, "requested b", base + 1);
1979-
insert_event(&pool, community, &requested_older, Some(requested_b))
1980-
.await
1981-
.expect("insert requested b candidate");
1993+
.expect("insert member workflow");
19821994

19831995
let events = query_events(
19841996
&pool,
19851997
&EventQuery {
1986-
kinds: Some(vec![39_001]),
1987-
exact_channel_ids: Some(vec![requested_a, requested_b]),
1998+
kinds: Some(vec![30_620]),
1999+
member_channel_pubkey: Some(member_pubkey),
19882000
limit: Some(2),
19892001
..EventQuery::for_community(community)
19902002
},
19912003
)
19922004
.await
1993-
.expect("query exact-channel page");
1994-
1995-
assert_eq!(events.len(), 2, "exact-channel page must fill before EOF");
1996-
assert_eq!(events[0].event.id, requested_newer.id);
1997-
assert_eq!(events[1].event.id, requested_older.id);
1998-
assert!(
1999-
events.iter().all(|stored| stored.channel_id.is_some()),
2000-
"an explicit #h list must exclude channel-less global events"
2001-
);
2002-
}
2003-
2004-
#[tokio::test]
2005-
#[ignore = "requires Postgres"]
2006-
async fn empty_exact_channel_scope_matches_nothing() {
2007-
let pool = setup_pool().await;
2008-
let community_uuid = make_test_community(&pool).await;
2009-
let community = CommunityId::from_uuid(community_uuid);
2010-
let channel = make_test_channel(&pool, community_uuid, None).await;
2011-
let event = make_event_at(39_002, "must not match", 1_800_200_000);
2012-
insert_event(&pool, community, &event, Some(channel))
2013-
.await
2014-
.expect("insert candidate");
2015-
2016-
let events = query_events(
2017-
&pool,
2018-
&EventQuery {
2019-
kinds: Some(vec![39_002]),
2020-
exact_channel_ids: Some(vec![]),
2021-
..EventQuery::for_community(community)
2022-
},
2023-
)
2024-
.await
2025-
.expect("query empty exact-channel scope");
2005+
.expect("query member workflow page");
20262006

2027-
assert!(events.is_empty());
2007+
assert_eq!(events.len(), 1);
2008+
assert_eq!(events[0].event.id, member_workflow.id);
20282009
}
20292010

20302011
fn make_text_event(content: &str) -> nostr::Event {

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

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,6 +1001,85 @@ async fn query_events_authed(
10011001
));
10021002
}
10031003

1004+
// `member_channels` is an authenticated HTTP-bridge extension for global
1005+
// overviews of channel-scoped resources. Keep it deliberately narrow: the
1006+
// only current contract is workflow definitions, and mixed filters are
1007+
// rejected rather than falling back to the generic membership-array path.
1008+
let member_workflow_overview = raw_filters
1009+
.iter()
1010+
.zip(filters.iter())
1011+
.filter(|(raw, _)| extension_flag(raw, "member_channels"))
1012+
.collect::<Vec<_>>();
1013+
if !member_workflow_overview.is_empty() {
1014+
if member_workflow_overview.len() != filters.len() {
1015+
return Err(api_error(
1016+
StatusCode::BAD_REQUEST,
1017+
"member_channels filters cannot be mixed with ordinary filters",
1018+
));
1019+
}
1020+
1021+
let mut overview_events = Vec::new();
1022+
for (raw, filter) in member_workflow_overview {
1023+
let workflow_only = filter.kinds.as_ref().is_some_and(|kinds| {
1024+
kinds.len() == 1 && kinds.iter().all(|kind| kind.as_u16() == 30_620)
1025+
});
1026+
if !workflow_only || extract_channel_from_filter(filter).is_some() {
1027+
return Err(api_error(
1028+
StatusCode::BAD_REQUEST,
1029+
"member_channels requires kinds:[30620] without #h",
1030+
));
1031+
}
1032+
1033+
let mut query = crate::handlers::req::build_event_query_from_filter(
1034+
filter,
1035+
&pubkey_bytes,
1036+
state,
1037+
tenant.community(),
1038+
)
1039+
.await;
1040+
query.member_channel_pubkey = Some(pubkey_bytes.clone());
1041+
1042+
match extract_before_id(raw) {
1043+
BeforeId::Malformed => {
1044+
return Err(api_error(
1045+
StatusCode::BAD_REQUEST,
1046+
"before_id must be a 64-char hex event id",
1047+
));
1048+
}
1049+
BeforeId::Valid(before_id) => {
1050+
if query.until.is_none() {
1051+
return Err(api_error(
1052+
StatusCode::BAD_REQUEST,
1053+
"before_id requires until to be set",
1054+
));
1055+
}
1056+
query.before_id = Some(before_id);
1057+
}
1058+
BeforeId::Absent => {}
1059+
}
1060+
1061+
let stored_events = state
1062+
.db
1063+
.query_events(&query)
1064+
.await
1065+
.map_err(|e| internal_error(&format!("member workflow query error: {e}")))?;
1066+
for stored in stored_events {
1067+
if buzz_core::filter::filters_match(std::slice::from_ref(filter), &stored)
1068+
&& buzz_core::filter::reader_authorized_for_event(
1069+
&stored.event,
1070+
&authed_pubkey_hex,
1071+
)
1072+
{
1073+
overview_events.push(
1074+
serde_json::to_value(&stored.event)
1075+
.map_err(|e| internal_error(&format!("event serialize: {e}")))?,
1076+
);
1077+
}
1078+
}
1079+
}
1080+
return Ok(Json(Value::Array(overview_events)));
1081+
}
1082+
10041083
// Get channels this user can access — same enforcement as WS REQ handler.
10051084
let accessible_channels = state
10061085
.get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes)

0 commit comments

Comments
 (0)