Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 172 additions & 1 deletion crates/buzz-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,85 @@ pub const P_GATED_KINDS: &[u32] = &[
/// `(pubkey, kind, d_tag)` where `d_tag` is the plaintext persona slug.
/// Content is a JSON body containing persona fields (system_prompt,
/// display_name, avatar_url, runtime, model, provider, name_pool).
/// Designed for discoverability and sharing — d-tag is not blinded.
///
/// # Access control: author-only-unless-shared
///
/// Kind 30175 uses **shared-tag-gated** read semantics to protect system
/// prompts and `respond_to_allowlist` pubkeys from being visible to all
/// community members as a side-effect of device sync:
///
/// - Events WITHOUT a `["shared", "true"]` tag are readable only by their
/// author. Foreign REQ/COUNT/fan-out/ids-lookup requests silently omit them.
/// - Events WITH exactly `["shared", "true"]` are readable community-wide,
/// enabling the opt-in agent catalog (`{kinds:[30175]}` all-authors).
///
/// Device sync already queries `authors:[self]`, so this gate never affects
/// self-reads. The `shared` tag is a tag (not a content field) so toggling
/// sharing does not change content bytes or the drift/`source_version` hash
/// (`persona_content_hash`) used by persona sync.
///
/// Ingest rejects malformed `shared` tags (any value other than `"true"`,
/// or more than one `shared` tag) so no ambiguous heads can exist.
pub const KIND_PERSONA: u32 = 30175;

/// Returns `true` if `kind` uses the author-only-unless-shared read model
/// (currently only `KIND_PERSONA` / 30175).
///
/// Events of these kinds may only be delivered to foreign readers when the
/// event carries exactly `["shared", "true"]`. Used by all relay read
/// chokepoints: REQ historical delivery, live fan-out, COUNT fallback,
/// and the `ids`-lookup result gate.
pub fn is_persona_shared_kind(kind: u32) -> bool {
kind == KIND_PERSONA
}

/// Returns `true` if the event is a persona-shared-catalog kind AND the
/// requester is NOT the author AND the event does NOT carry `["shared",
/// "true"]`. All three conditions must hold to withhold the event.
///
/// This is the per-event gate used by REQ historical delivery, live fan-out,
/// and COUNT fallback paths. It is intentionally independent of
/// `is_author_only_event` — persona events with `["shared", "true"]` MUST
/// reach foreign readers; stripping them at the author-only layer would break
/// the catalog query.
pub fn is_unshared_persona_event(event: &nostr::Event, requester_pubkey_bytes: &[u8]) -> bool {
let kind = event.kind.as_u16() as u32;
if !is_persona_shared_kind(kind) {
return false;
}
// Author reads are always allowed.
if event.pubkey.to_bytes() == requester_pubkey_bytes {
return false;
}
// Foreign reader: allowed only if the event is explicitly shared.
!persona_event_is_shared(event)
}

/// Returns `true` if the event carries exactly one `["shared", "true"]` tag.
///
/// Requires the tag to have exactly two elements so that a three-element shape
/// like `["shared","true","extra"]` is NOT treated as shared. Ingest enforces
/// the same exact shape, so a well-stored event either has no `shared` tag
/// (author-only) or exactly one with precisely two elements and value `"true"`
/// (community-readable). This helper fails closed on any non-exact shape
/// independently of ingest guarantees.
pub fn persona_event_is_shared(event: &nostr::Event) -> bool {
let mut count = 0usize;
for tag in event.tags.iter() {
let parts = tag.as_slice();
if parts.len() == 2 && parts[0].as_str() == "shared" {
if parts[1].as_str() != "true" {
return false;
}
count += 1;
} else if !parts.is_empty() && parts[0].as_str() == "shared" {
// Non-exact shape (wrong length) — fail closed: not shared.
return false;
}
}
count == 1
}

/// NIP-AP: Agent Team (parameterized replaceable, owner-authored).
///
/// Team definition event published by the workspace owner. Addressed by
Expand Down Expand Up @@ -781,4 +857,99 @@ mod tests {
);
}
}

// ── persona_event_is_shared / is_unshared_persona_event ──────────────

fn make_persona_event(tags: &[&[&str]]) -> nostr::Event {
use nostr::{EventBuilder, Keys, Kind, Tag};
let keys = Keys::generate();
let tag_vec: Vec<Tag> = tags
.iter()
.map(|parts| Tag::parse(parts.iter().copied()).unwrap())
.collect();
EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "")
.tags(tag_vec)
.sign_with_keys(&keys)
.unwrap()
}

#[test]
fn persona_event_is_shared_true_tag() {
let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "true"]]);
assert!(persona_event_is_shared(&ev));
}

#[test]
fn persona_event_is_shared_no_tag() {
let ev = make_persona_event(&[&["d", "my-agent"]]);
assert!(!persona_event_is_shared(&ev));
}

#[test]
fn persona_event_is_shared_wrong_value() {
let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "false"]]);
assert!(!persona_event_is_shared(&ev));
}

#[test]
fn persona_event_is_shared_duplicate_shared_tags() {
// Two ["shared","true"] tags → ambiguous; not considered shared.
let ev =
make_persona_event(&[&["d", "my-agent"], &["shared", "true"], &["shared", "true"]]);
assert!(!persona_event_is_shared(&ev));
}

#[test]
fn persona_event_is_shared_three_element_tag_not_shared() {
// ["shared","true","extra"] — three elements — must NOT be treated as shared.
// The helper fails closed on any non-exact shape independently of ingest guarantees.
let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "true", "extra"]]);
assert!(!persona_event_is_shared(&ev));
}

#[test]
fn persona_event_is_shared_one_element_tag_not_shared() {
// ["shared"] — only one element — not shared (fails the == 2 check).
let ev = make_persona_event(&[&["d", "my-agent"], &["shared"]]);
assert!(!persona_event_is_shared(&ev));
}

#[test]
fn is_unshared_persona_event_author_always_allowed() {
// Even without a shared tag the event author should not be blocked.
use nostr::{EventBuilder, Keys, Kind, Tag};
let keys = Keys::generate();
let ev = EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "")
.tags(vec![Tag::parse(["d", "my-agent"]).unwrap()])
.sign_with_keys(&keys)
.unwrap();
let author_bytes = keys.public_key().to_bytes();
assert!(!is_unshared_persona_event(&ev, &author_bytes));
}

#[test]
fn is_unshared_persona_event_foreign_no_tag() {
let ev = make_persona_event(&[&["d", "my-agent"]]);
let foreign = [0u8; 32];
assert!(is_unshared_persona_event(&ev, &foreign));
}

#[test]
fn is_unshared_persona_event_foreign_shared_tag() {
let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "true"]]);
let foreign = [0u8; 32];
assert!(!is_unshared_persona_event(&ev, &foreign));
}

#[test]
fn is_unshared_persona_event_non_persona_kind_passthrough() {
use nostr::{EventBuilder, Keys, Kind};
let keys = Keys::generate();
let ev = EventBuilder::new(Kind::Custom(KIND_TEAM as u16), "")
.sign_with_keys(&keys)
.unwrap();
let foreign = [0u8; 32];
// Non-persona kinds are never blocked by this gate.
assert!(!is_unshared_persona_event(&ev, &foreign));
}
}
41 changes: 41 additions & 0 deletions crates/buzz-db/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,21 @@ pub struct EventQuery {
/// which needs to fetch all matching events for post-filter counting.
/// When None, the default clamp of 1000 applies.
pub max_limit: Option<i64>,
/// Persona visibility reader: when set, append an SQL visibility clause
/// for kind 30175 before ORDER/LIMIT so private personas are excluded from
/// the candidate page rather than discarded after it.
///
/// The clause is: `AND (kind != 30175 OR pubkey = $reader OR tags @> ?)`,
/// where `?` is the JSONB literal `[["shared","true"]]`. The GIN index on
/// `tags` (migration 0004, jsonb_path_ops) makes the containment check fast.
///
/// NOTE: `tags @> '[["shared","true"]]'` uses JSONB containment, which
/// matches any tag array that is a superset of `[["shared","true"]]` — it
/// would match `["shared","true","extra"]` too. The ingest `parts.len() ==
/// 2` exact-shape check ensures such malformed tags are never stored, so the
/// SQL pushdown is sound. Keeping `event_visible_to_reader` as post-filter
/// defense-in-depth catches any residual mismatch.
pub persona_reader: Option<Vec<u8>>,
}

impl EventQuery {
Expand Down Expand Up @@ -99,6 +114,7 @@ impl EventQuery {
e_tags: None,
channel_ids: None,
max_limit: None,
persona_reader: None,
}
}
}
Expand Down Expand Up @@ -485,6 +501,31 @@ pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result<Vec<StoredEve
}
}

// Persona visibility pushdown: exclude kind 30175 events that are neither
// authored by the reader nor explicitly shared. Applied BEFORE ORDER/LIMIT
// so that a page of newer private personas does not push visible shared ones
// off the end of the result set (the catalog query pattern).
//
// Clause: AND (kind != 30175 OR pubkey = $reader OR tags @> '[["shared","true"]]')
//
// The JSONB containment check is served by idx_events_tags_gin (migration
// 0004, jsonb_path_ops). `tags @> '[["shared","true"]]'` matches any array
// that contains exactly the sub-array — a two-element `["shared","true"]`
// tag passes; a tag-absent event does not. Because ingest now requires
// exactly two elements for the shared tag (parts.len() == 2), no stored
// event can carry a three-element superset.
if let Some(ref reader_bytes) = q.persona_reader {
let kind_30175: i32 = 30175;
let shared_containment = serde_json::json!([["shared", "true"]]);
qb.push(format!(" AND ({col_prefix}kind != "));
qb.push_bind(kind_30175);
qb.push(format!(" OR {col_prefix}pubkey = "));
qb.push_bind(reader_bytes.clone());
qb.push(format!(" OR {col_prefix}tags @> "));
qb.push_bind(shared_containment);
qb.push(")");
}

// Composite ordering for deterministic pagination across ALL callers of
// query_events (WebSocket REQ, REST endpoints, canvas, notes, etc.).
// The `id ASC` tiebreaker ensures stable results when events share the
Expand Down
60 changes: 39 additions & 21 deletions crates/buzz-relay/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1224,6 +1224,11 @@ async fn query_events_authed(
extract_channel_from_filter(filter),
&accessible_channels,
);
// Persona visibility pushdown: must mirror WS REQ so that a page of newer
// private personas does not starve older shared ones off the candidate page.
if crate::handlers::req::filter_can_match_persona_shared_kinds(filter) {
query.persona_reader = Some(pubkey_bytes.clone());
}

match extract_before_id(raw) {
BeforeId::Malformed => {
Expand Down Expand Up @@ -1284,13 +1289,10 @@ async fn query_events_authed(
}
// 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) {
// 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) {
Expand Down Expand Up @@ -1439,20 +1441,30 @@ async fn count_events_authed(
filter,
&authed_pubkey_hex,
);
// Force per-event fallback for filters that can match kind:30175 —
// the fast SQL count_events() path has no per-event gate and would
// over-count foreign unshared persona events (existence leak).
let needs_persona_filtering =
crate::handlers::req::filter_can_match_persona_shared_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(
let mut query = crate::handlers::req::build_event_query_from_filter(
filter,
&pubkey_bytes,
state,
tenant.community(),
)
.await;
// Persona visibility pushdown: same as REQ and /query paths, so the
// fallback's query_events call doesn't over-fetch private persona rows.
if needs_persona_filtering {
query.persona_reader = Some(pubkey_bytes.clone());
}
let author_is_self = filter.authors.as_ref().is_some_and(|authors| {
!authors.is_empty()
&& authors
Expand All @@ -1462,6 +1474,7 @@ async fn count_events_authed(
if crate::handlers::req::filter_fully_pushable(filter)
&& (!needs_author_only_filtering || author_is_self)
&& !needs_result_gated_filtering
&& !needs_persona_filtering
{
match state.db.count_events(&query).await {
Ok(n) => total += n as u64,
Expand All @@ -1487,13 +1500,9 @@ async fn count_events_authed(
{
continue;
}
if crate::handlers::req::is_author_only_event(&se.event, &pubkey_bytes)
{
continue;
}
if !buzz_core::filter::reader_authorized_for_event(
if !crate::handlers::req::event_visible_to_reader(
&se.event,
&authed_pubkey_hex,
&pubkey_bytes,
) {
continue;
}
Expand All @@ -1516,6 +1525,11 @@ async fn count_events_authed(
)
.await;
query.channel_ids = Some(accessible_channels.to_vec());
// Persona visibility pushdown: pre-filter before ORDER/LIMIT on the
// fallback query_events path.
if needs_persona_filtering {
query.persona_reader = Some(pubkey_bytes.clone());
}

let author_is_self = filter.authors.as_ref().is_some_and(|authors| {
!authors.is_empty()
Expand All @@ -1526,6 +1540,7 @@ async fn count_events_authed(
if crate::handlers::req::filter_fully_pushable(filter)
&& (!needs_author_only_filtering || author_is_self)
&& !needs_result_gated_filtering
&& !needs_persona_filtering
{
query.limit = None;
match state.db.count_events(&query).await {
Expand All @@ -1551,13 +1566,9 @@ async fn count_events_authed(
{
continue;
}
if crate::handlers::req::is_author_only_event(&se.event, &pubkey_bytes)
{
continue;
}
if !buzz_core::filter::reader_authorized_for_event(
if !crate::handlers::req::event_visible_to_reader(
&se.event,
&authed_pubkey_hex,
&pubkey_bytes,
) {
continue;
}
Expand Down Expand Up @@ -1732,7 +1743,14 @@ async fn handle_bridge_search(
if !search_hit_accepted(filter, stored, accessible_channels, reader_pubkey_hex) {
continue;
}
if crate::handlers::req::is_author_only_event(&stored.event, pubkey_bytes) {
// 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.
Expand Down
Loading
Loading