diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index b912169801..afec52305a 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -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 @@ -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 = 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)); + } } diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index 5adcb05bdc..520fd1536f 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -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, + /// 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>, } impl EventQuery { @@ -99,6 +114,7 @@ impl EventQuery { e_tags: None, channel_ids: None, max_limit: None, + persona_reader: None, } } } @@ -485,6 +501,31 @@ pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result '[["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 diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 8372e49a2a..c8ec0cdbc9 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -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 => { @@ -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) { @@ -1439,6 +1441,11 @@ 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) { @@ -1446,13 +1453,18 @@ async fn count_events_authed( 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 @@ -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, @@ -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; } @@ -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() @@ -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 { @@ -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; } @@ -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. diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index 4689826f23..614e54d7a0 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -7,7 +7,8 @@ use tracing::warn; use crate::connection::{AuthState, ConnectionState}; use crate::handlers::req::{ - filter_can_match_result_gated_kinds, is_author_only_event, result_gated_count_safe_for_pushdown, + event_visible_to_reader, filter_can_match_persona_shared_kinds, + filter_can_match_result_gated_kinds, result_gated_count_safe_for_pushdown, }; use crate::protocol::RelayMessage; use crate::state::AppState; @@ -102,6 +103,11 @@ pub async fn handle_count( // fast-path count_events() cannot be used because it doesn't do // per-event author filtering. let needs_author_only_filtering = super::req::filter_can_match_author_only_kinds(filter); + // Determine if this filter can match kind 30175 (persona) — if so, the + // fast-path must be bypassed because it has no per-event shared-tag check. + // A fast count over 30175 would include foreign unshared persona events, + // leaking the existence of private agent activity. + let needs_persona_filtering = filter_can_match_persona_shared_kinds(filter); // Determine if this filter can match result-gated kinds (44200, 30622) // that require a per-event owner check. When the fast SQL path would // count matching rows without calling reader_authorized_for_event, a @@ -144,13 +150,18 @@ pub async fn handle_count( continue; // Skip filters targeting inaccessible channels. } // Channel is accessible — count with pushability check. - let query = super::req::build_event_query_from_filter( + let mut query = super::req::build_event_query_from_filter( filter, &pubkey_bytes, &state, conn.tenant.community(), ) .await; + // Persona visibility pushdown: pre-filter the fallback query_events + // candidate page before ORDER/LIMIT. + 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 @@ -160,6 +171,7 @@ pub async fn handle_count( if super::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, @@ -187,13 +199,7 @@ pub async fn handle_count( { continue; } - if is_author_only_event(&se.event, &pubkey_bytes) { - continue; - } - if !buzz_core::filter::reader_authorized_for_event( - &se.event, - &authed_pubkey_hex, - ) { + if !event_visible_to_reader(&se.event, &pubkey_bytes) { continue; } total += 1; @@ -220,6 +226,10 @@ pub async fn handle_count( ) .await; query.channel_ids = Some(accessible_channels.to_vec()); + // Persona visibility pushdown for 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() @@ -230,6 +240,7 @@ pub async fn handle_count( if super::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) && !needs_result_gated_filtering + && !needs_persona_filtering { query.limit = None; // COUNT doesn't need a row limit match state.db.count_events(&query).await { @@ -257,13 +268,7 @@ pub async fn handle_count( { continue; } - if is_author_only_event(&se.event, &pubkey_bytes) { - continue; - } - if !buzz_core::filter::reader_authorized_for_event( - &se.event, - &authed_pubkey_hex, - ) { + if !event_visible_to_reader(&se.event, &pubkey_bytes) { continue; } total += 1; diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index ecb3e41fcc..88dd5f5180 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -7,8 +7,8 @@ use tracing::{debug, error, info, warn}; use buzz_core::event::StoredEvent; use buzz_core::kind::{ - event_kind_u32, is_ephemeral, AUTHOR_ONLY_KINDS, KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP, - KIND_PRESENCE_UPDATE, + event_kind_u32, is_ephemeral, is_unshared_persona_event, AUTHOR_ONLY_KINDS, + KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP, KIND_PRESENCE_UPDATE, }; use buzz_core::observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -151,6 +151,29 @@ pub async fn filter_fanout_by_access( matches }; + // Persona shared-read gate (fan-out): kind 30175 events fan out to all + // connections only when carrying ["shared","true"]. Unshared personas + // are delivered only to the author's own connections, matching REQ semantics. + let matches = if buzz_core::kind::is_persona_shared_kind(event_kind_u32(&stored_event.event)) { + let author = stored_event.event.pubkey.to_bytes(); + matches + .into_iter() + .filter(|(conn_id, _)| { + let Some(pk) = state.conn_manager.pubkey_for_conn(*conn_id) else { + return false; + }; + // Author always receives their own events. + if pk == author { + return true; + } + // Foreign connection: allowed only if the event is shared. + !is_unshared_persona_event(&stored_event.event, &pk) + }) + .collect() + } else { + matches + }; + let Some(channel_id) = stored_event.channel_id else { return matches; }; diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index ca529d1db6..497adc6c73 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -1021,16 +1021,42 @@ fn validate_engram_envelope(event: &Event) -> Result<(), String> { /// Enforces: /// * exactly one `d` tag with a non-empty value matching the slug grammar /// `^[a-z0-9][a-z0-9_-]{0,63}$`. +/// * at most one `shared` tag; if present, its value must be exactly `"true"`. /// -/// Without this, an empty d-tag collapses every persona into the +/// Without the `d`-tag check, an empty d-tag collapses every persona into the /// `(pubkey, 30175, "")` slot — last-write-wins data loss. +/// +/// The `shared` tag rule ensures no ambiguous heads: either an event has no +/// `shared` tag (author-only) or exactly `["shared", "true"]` (community- +/// readable). Any other value (`"false"`, `"1"`, extra tags) is rejected at +/// ingest so read-path helpers can treat stored events as unambiguously one or +/// the other. fn validate_persona_envelope(event: &Event) -> Result<(), String> { let mut d_tags: Vec<&str> = Vec::new(); + let mut shared_count = 0usize; for tag in event.tags.iter() { let parts = tag.as_slice(); if parts.len() >= 2 && parts[0].as_str() == "d" { d_tags.push(&parts[1]); } + if !parts.is_empty() && parts[0].as_str() == "shared" { + // Exact shape required: ["shared", "true"] — exactly two elements, + // second element exactly "true". Extra elements are rejected so that + // a three-element tag like ["shared","true","extra"] cannot be stored + // and later misread as shared by the SQL-level visibility clause. + if parts.len() != 2 || parts[1].as_str() != "true" { + return Err(format!( + "persona event `shared` tag must be exactly [\"shared\",\"true\"] (got {:?})", + parts.iter().map(|s| s.as_str()).collect::>() + )); + } + shared_count += 1; + } + } + if shared_count > 1 { + return Err(format!( + "persona event must have at most one `shared` tag (got {shared_count})" + )); } if d_tags.len() != 1 { return Err(format!( @@ -3534,6 +3560,89 @@ mod tests { assert!(err.contains("`d` tag"), "got: {err}"); } + // ─── persona shared-tag envelope tests ─────────────────────────────────── + + #[test] + fn persona_envelope_accepts_shared_true() { + // A persona event with exactly one ["shared","true"] tag must be accepted. + let ev = make_persona(&[&["d", "my-persona"], &["shared", "true"]]); + assert!(validate_persona_envelope(&ev).is_ok()); + } + + #[test] + fn persona_envelope_accepts_no_shared_tag() { + // The shared tag is optional; omitting it is the author-only default. + let ev = make_persona(&[&["d", "my-persona"]]); + assert!(validate_persona_envelope(&ev).is_ok()); + } + + #[test] + fn persona_envelope_rejects_shared_false() { + let ev = make_persona(&[&["d", "my-persona"], &["shared", "false"]]); + let err = validate_persona_envelope(&ev).unwrap_err(); + assert!( + err.contains("\"true\""), + "expected 'true' in error, got: {err}" + ); + } + + #[test] + fn persona_envelope_rejects_shared_wrong_value() { + let ev = make_persona(&[&["d", "my-persona"], &["shared", "yes"]]); + let err = validate_persona_envelope(&ev).unwrap_err(); + assert!( + err.contains("\"true\""), + "expected 'true' in error, got: {err}" + ); + } + + #[test] + fn persona_envelope_rejects_shared_missing_value() { + // A "shared" tag with no value argument must be rejected. + let ev = make_event_with_tags( + KIND_PERSONA, + r#"{"display_name":"x"}"#, + &[&["d", "slug"], &["shared"]], + ); + let err = validate_persona_envelope(&ev).unwrap_err(); + assert!( + err.contains("\"true\""), + "expected 'true' in error, got: {err}" + ); + } + + #[test] + fn persona_envelope_rejects_duplicate_shared_tags() { + // More than one shared tag, even if both are "true", must be rejected. + let ev = make_persona(&[ + &["d", "my-persona"], + &["shared", "true"], + &["shared", "true"], + ]); + let err = validate_persona_envelope(&ev).unwrap_err(); + assert!( + err.contains("at most one"), + "expected 'at most one' in error, got: {err}" + ); + } + + #[test] + fn persona_envelope_rejects_shared_three_elements() { + // ["shared","true","extra"] must be rejected — only exactly two elements + // are valid so the SQL containment check tags @> '[["shared","true"]]' + // cannot match a three-element stored tag. + let ev = make_event_with_tags( + KIND_PERSONA, + r#"{"display_name":"x"}"#, + &[&["d", "slug"], &["shared", "true", "extra"]], + ); + let err = validate_persona_envelope(&ev).unwrap_err(); + assert!( + err.contains("[\"shared\",\"true\"]"), + "expected exact-shape error, got: {err}" + ); + } + // ─── agent_turn_metric envelope tests ──────────────────────────────────── /// Build an event for kind:44200 with the given tags and content. diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index ecc3ba6777..d3ddd3e5d3 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -7,8 +7,8 @@ use tracing::{debug, warn}; use buzz_core::filter::filters_match; use buzz_core::kind::{ - AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC, KIND_DM_VISIBILITY, - P_GATED_KINDS, RESULT_GATED_KINDS, + is_unshared_persona_event, AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC, + KIND_DM_VISIBILITY, KIND_PERSONA, P_GATED_KINDS, RESULT_GATED_KINDS, }; use buzz_core::tenant::TenantContext; use buzz_db::EventQuery; @@ -223,7 +223,6 @@ pub async fn handle_req( &accessible_channels, token_channel_ids.is_none(), &conn.tenant, - &hex::encode(&pubkey_bytes), &pubkey_bytes, &conn, &state, @@ -264,7 +263,6 @@ pub async fn handle_req( // per-filter limits or non-overlapping time windows. let mut seen_ids: HashSet = HashSet::new(); let mut total_sent: usize = 0; - let viewer_hex = hex::encode(&pubkey_bytes); // Phase 1 — pure query construction, in filter order. let filter_queries: Vec<(usize, Option, EventQuery)> = filters @@ -292,6 +290,12 @@ pub async fn handle_req( let mut params = filter_to_query_params(filter, per_filter_channel, conn.tenant.community()); apply_access_scope_to_query(&mut params, per_filter_channel, &accessible_channels); + // Persona visibility pushdown: set reader bytes so query_events appends + // the SQL visibility clause before ORDER/LIMIT, preventing newer private + // personas from starving older shared ones off the page. + if filter_can_match_persona_shared_kinds(filter) { + params.persona_reader = Some(pubkey_bytes.clone()); + } (idx, per_filter_channel, params) }) .collect(); @@ -378,13 +382,10 @@ pub async fn handle_req( // Result-level read auth: a viewer-private snapshot (kind:30622) is // delivered only to its owner, even if reached via a kindless // `ids:[…]` subscription that skips the filter-level `#p` gate. - if !buzz_core::filter::reader_authorized_for_event(&stored.event, &viewer_hex) { - continue; - } - - // Author-only kinds: only the event author may see these events. - // Mixed-kind filters still serve other kinds normally. - if is_author_only_event(&stored.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 !event_visible_to_reader(&stored.event, &pubkey_bytes) { continue; } @@ -507,7 +508,6 @@ async fn handle_search_req( accessible_channels: &[uuid::Uuid], include_global: bool, tenant: &TenantContext, - reader_pubkey_hex: &str, reader_pubkey_bytes: &[u8], conn: &ConnectionState, state: &AppState, @@ -700,13 +700,9 @@ async fn handle_search_req( continue; } } - if !buzz_core::filter::reader_authorized_for_event( - &stored.event, - reader_pubkey_hex, - ) { - continue; - } - if is_author_only_event(&stored.event, reader_pubkey_bytes) { + // Result-level gate: covers author-only, persona shared-gate, + // and result-gated kinds in one call. + if !event_visible_to_reader(&stored.event, reader_pubkey_bytes) { continue; } // Dedup AFTER acceptance — an event that fails filter A's constraints @@ -1141,6 +1137,21 @@ pub(crate) fn filter_can_match_author_only_kinds(filter: &Filter) -> bool { }) } +/// Returns `true` if the filter CAN match kind 30175 (persona) — meaning it +/// either has no `kinds` constraint (wildcard) or explicitly includes 30175. +/// +/// Used by the COUNT handler to force the per-event fallback path, which calls +/// `is_unshared_persona_event` on each row. The fast SQL `count_events()` path +/// has no per-event access check, so it would over-count foreign unshared +/// persona events — leaking the existence of persona activity even without +/// returning content. +pub(crate) fn filter_can_match_persona_shared_kinds(filter: &Filter) -> bool { + filter + .kinds + .as_ref() + .is_none_or(|ks| ks.iter().any(|k| k.as_u16() as u32 == KIND_PERSONA)) +} + /// Returns `true` if the filter CAN match result-gated kinds — meaning it /// either has no `kinds` constraint (wildcard) or includes at least one kind /// that carries a per-event result-level read gate (currently @@ -1188,6 +1199,40 @@ pub(crate) fn is_author_only_event(event: &nostr::Event, requester_pubkey_bytes: AUTHOR_ONLY_KINDS.contains(&kind_u32) && event.pubkey.to_bytes() != requester_pubkey_bytes } +/// Combined per-event result-visibility check for all gated event classes. +/// +/// Returns `true` if the `event` should be delivered to / counted for the +/// reader identified by `requester_pubkey_bytes` (raw 32-byte public key). +/// Returns `false` and the event must be silently omitted if any of the +/// following hold: +/// +/// 1. **Author-only kinds** (`AUTHOR_ONLY_KINDS`, e.g. kind 30300/30350): only +/// the author may read their own events. +/// 2. **Persona shared-gate** (kind 30175 without `["shared","true"]`): the +/// event is only visible to the author unless explicitly opted into sharing. +/// 3. **Result-gated kinds** (kind 44200/30622 etc.): `reader_authorized_for_event` +/// carries the per-event ownership check. +/// +/// The hex representation required by `reader_authorized_for_event` is derived +/// internally so callers cannot supply inconsistent byte/hex identities. +/// +/// Call this from every read surface — both WS (REQ/COUNT/fan-out) and HTTP +/// (NIP-98 `/query`, `/count`, FTS search) — instead of inlining the three +/// individual predicates at each site. +pub(crate) fn event_visible_to_reader(event: &nostr::Event, requester_pubkey_bytes: &[u8]) -> bool { + if is_author_only_event(event, requester_pubkey_bytes) { + return false; + } + if is_unshared_persona_event(event, requester_pubkey_bytes) { + return false; + } + let requester_pubkey_hex = hex::encode(requester_pubkey_bytes); + if !buzz_core::filter::reader_authorized_for_event(event, &requester_pubkey_hex) { + return false; + } + true +} + /// Pre-filter authorization for filters that exclusively target author-only kinds. /// /// If a filter targets ONLY author-only kinds (e.g. `{kinds:[30300]}`), the diff --git a/crates/buzz-test-client/tests/e2e_persona.rs b/crates/buzz-test-client/tests/e2e_persona.rs index 54a0f19c30..b3b1f7f6b2 100644 --- a/crates/buzz-test-client/tests/e2e_persona.rs +++ b/crates/buzz-test-client/tests/e2e_persona.rs @@ -15,8 +15,10 @@ use std::time::Duration; -use buzz_test_client::BuzzTestClient; +use buzz_test_client::{BuzzTestClient, RelayMessage}; use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp}; +use reqwest::Client; +use serde_json::Value; const PERSONA_KIND: u16 = 30175; @@ -24,10 +26,114 @@ fn relay_url() -> String { std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) } +fn relay_http_url() -> String { + relay_url() + .replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches('/') + .to_string() +} + +fn http_client() -> Client { + Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .expect("failed to build HTTP client") +} + +/// Submit an event via the NIP-98 HTTP bridge (`POST /events`). +async fn submit_event_http(client: &Client, keys: &Keys, event: &nostr::Event) -> (bool, String) { + let pubkey_hex = keys.public_key().to_hex(); + let resp = client + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", &pubkey_hex) + .header("Content-Type", "application/json") + .body(serde_json::to_string(event).unwrap()) + .send() + .await + .expect("submit event"); + let status = resp.status().as_u16(); + let body: Value = resp.json().await.expect("parse response"); + if status == 200 { + let accepted = body["accepted"].as_bool().unwrap_or(false); + let message = body["message"].as_str().unwrap_or("").to_string(); + (accepted, message) + } else { + let message = body["error"].as_str().unwrap_or("").to_string(); + (false, message) + } +} + +/// Query events via the NIP-98 HTTP bridge (`POST /query`). +async fn query_events_http(client: &Client, pubkey_hex: &str, filters: Vec) -> Vec { + let resp = client + .post(format!("{}/query", relay_http_url())) + .header("X-Pubkey", pubkey_hex) + .header("Content-Type", "application/json") + .json(&filters) + .send() + .await + .expect("query events"); + assert!( + resp.status().is_success(), + "query failed: {}", + resp.status() + ); + resp.json::>() + .await + .expect("parse query response") +} + +/// Count events via the NIP-98 HTTP bridge (`POST /count`). +async fn count_events_http( + client: &Client, + pubkey_hex: &str, + filters: Vec, +) -> Result { + let resp = client + .post(format!("{}/count", relay_http_url())) + .header("X-Pubkey", pubkey_hex) + .header("Content-Type", "application/json") + .json(&filters) + .send() + .await + .expect("count events"); + let status = resp.status().as_u16(); + let body: Value = resp.json().await.expect("parse count response"); + if status == 200 { + Ok(body["count"].as_u64().unwrap_or(0)) + } else { + let msg = body["error"].as_str().unwrap_or("").to_string(); + Err((status, msg)) + } +} + fn sub_id(name: &str) -> String { format!("e2e-persona-{name}-{}", uuid::Uuid::new_v4()) } +/// Create an open-visibility channel so a kind:9 event can be read by any relay member. +/// +/// Uses `visibility=open` so the foreign reader does not need an explicit membership +/// entry — the relay allows any authenticated member to read open-channel events. +async fn create_test_channel(keys: &Keys) -> String { + let channel_uuid = uuid::Uuid::new_v4(); + let channel_name = format!("persona-e2e-{channel_uuid}"); + let event = EventBuilder::new(Kind::Custom(9007), "") + .tags(vec![ + Tag::parse(["h", &channel_uuid.to_string()]).unwrap(), + Tag::parse(["name", &channel_name]).unwrap(), + Tag::parse(["channel_type", "stream"]).unwrap(), + Tag::parse(["visibility", "open"]).unwrap(), + ]) + .sign_with_keys(keys) + .unwrap(); + + let (ok, msg) = submit_event_http(&http_client(), keys, &event).await; + assert!(ok, "channel creation rejected: {msg}"); + channel_uuid.to_string() +} + /// Build a minimal persona event with the given d-tag and content. fn persona_event(keys: &Keys, d_tag: &str, content: &str) -> nostr::Event { EventBuilder::new(Kind::Custom(PERSONA_KIND), content) @@ -504,3 +610,982 @@ async fn test_persona_multiple_per_author() { client.disconnect().await.expect("disconnect"); } + +// ───────────────────────────────────────────────────────────────────────────── +// Shared-read-gate tests (author-only-unless-shared semantics for kind:30175) +// ───────────────────────────────────────────────────────────────────────────── + +/// Build a persona event with an optional `["shared","true"]` tag. +fn persona_event_with_shared(keys: &Keys, d_tag: &str, shared: bool) -> nostr::Event { + let mut tags = vec![Tag::parse(["d", d_tag]).unwrap()]; + if shared { + tags.push(Tag::parse(["shared", "true"]).unwrap()); + } + EventBuilder::new(Kind::Custom(PERSONA_KIND), r#"{"display_name":"test"}"#) + .tags(tags) + .sign_with_keys(keys) + .unwrap() +} + +/// Build a persona event with an optional `["shared","true"]` tag and an +/// explicit `created_at` — needed for NIP-33 replacement tests where two +/// events could otherwise land in the same second and be ordered by event-id +/// tie-break rather than timestamp. +fn persona_event_with_shared_at( + keys: &Keys, + d_tag: &str, + shared: bool, + created_at: u64, +) -> nostr::Event { + let mut tags = vec![Tag::parse(["d", d_tag]).unwrap()]; + if shared { + tags.push(Tag::parse(["shared", "true"]).unwrap()); + } + EventBuilder::new(Kind::Custom(PERSONA_KIND), r#"{"display_name":"test"}"#) + .tags(tags) + .custom_created_at(Timestamp::from(created_at)) + .sign_with_keys(keys) + .unwrap() +} + +/// AC-1: Foreign reader receives ONLY shared heads; author receives all own heads. +/// +/// Gate changed: `test_persona_publish_and_query` queries by id (author, always +/// allowed), so it is unaffected. The "all personas by author" variant in +/// `test_persona_multiple_per_author` uses `authors:[self]`, also unaffected. +/// This test is the cross-author assertion. +#[tokio::test] +#[ignore] +async fn test_persona_shared_read_gate_foreign_sees_only_shared() { + let url = relay_url(); + let author_keys = Keys::generate(); + let foreign_keys = Keys::generate(); + + let d_unshared = format!("priv-{}", &uuid::Uuid::new_v4().to_string()[..8]); + let d_shared = format!("pub-{}", &uuid::Uuid::new_v4().to_string()[..8]); + + // Author publishes one unshared and one shared persona. + let mut author = BuzzTestClient::connect(&url, &author_keys) + .await + .expect("connect author"); + let ev_unshared = persona_event_with_shared(&author_keys, &d_unshared, false); + let ev_shared = persona_event_with_shared(&author_keys, &d_shared, true); + let shared_id = ev_shared.id; + + let ok = author.send_event(ev_unshared).await.expect("send unshared"); + assert!(ok.accepted, "unshared ingest rejected: {}", ok.message); + let ok = author.send_event(ev_shared).await.expect("send shared"); + assert!(ok.accepted, "shared ingest rejected: {}", ok.message); + + // Foreign reader queries all personas by the author. + let mut foreign = BuzzTestClient::connect(&url, &foreign_keys) + .await + .expect("connect foreign"); + let sid = sub_id("fg-all"); + let filter = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()); + foreign + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + let events = foreign + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + // Foreign should see ONLY the shared event. + assert!( + !events.iter().any(|e| e.tags.iter().any(|t| t + .as_slice() + .get(1) + .is_some_and(|v| v.as_str() == d_unshared))), + "foreign should NOT see unshared persona" + ); + assert!( + events.iter().any(|e| e.id == shared_id), + "foreign should see the shared persona" + ); + + // Author queries all own personas — must see both. + let sid_author = sub_id("auth-all"); + let filter_self = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()); + author + .subscribe(&sid_author, vec![filter_self]) + .await + .expect("subscribe author"); + let author_events = author + .collect_until_eose(&sid_author, Duration::from_secs(5)) + .await + .expect("collect author"); + assert!( + author_events.len() >= 2, + "author should see both own personas, got {}", + author_events.len() + ); + + author.disconnect().await.expect("disconnect author"); + foreign.disconnect().await.expect("disconnect foreign"); +} + +/// AC-2: `{ids:[unshared-foreign-30175-id]}` returns nothing to a foreign reader. +#[tokio::test] +#[ignore] +async fn test_persona_ids_lookup_unshared_returns_nothing_to_foreign() { + let url = relay_url(); + let author_keys = Keys::generate(); + let foreign_keys = Keys::generate(); + + let d_tag = format!("priv-{}", &uuid::Uuid::new_v4().to_string()[..8]); + let ev = persona_event_with_shared(&author_keys, &d_tag, false); + let event_id = ev.id; + + let mut author = BuzzTestClient::connect(&url, &author_keys) + .await + .expect("connect author"); + let ok = author.send_event(ev).await.expect("send"); + assert!(ok.accepted, "ingest rejected: {}", ok.message); + author.disconnect().await.expect("disconnect"); + + let mut foreign = BuzzTestClient::connect(&url, &foreign_keys) + .await + .expect("connect foreign"); + let sid = sub_id("ids-unshared"); + let filter = Filter::new().id(event_id); + foreign + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + let events = foreign + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert!( + events.is_empty(), + "ids-lookup of unshared persona must return nothing to foreign reader, got {:?}", + events.iter().map(|e| e.id).collect::>() + ); + + foreign.disconnect().await.expect("disconnect"); +} + +/// AC-3: COUNT over 30175 uses the fallback path; foreign unshared events are excluded. +/// +/// The relay does not pre-block COUNT on persona kinds (unlike AUTHOR_ONLY_KINDS). +/// We verify the per-event fallback fires correctly by cross-checking: the foreign +/// reader's REQ count equals the shared-persona count, not total-persona count. +#[tokio::test] +#[ignore] +async fn test_persona_count_excludes_foreign_unshared() { + let url = relay_url(); + let author_keys = Keys::generate(); + let foreign_keys = Keys::generate(); + + let d_unshared = format!("priv-{}", &uuid::Uuid::new_v4().to_string()[..8]); + let d_shared = format!("pub-{}", &uuid::Uuid::new_v4().to_string()[..8]); + + // Publish one unshared and one shared persona. + let mut author = BuzzTestClient::connect(&url, &author_keys) + .await + .expect("connect author"); + let ok = author + .send_event(persona_event_with_shared(&author_keys, &d_unshared, false)) + .await + .expect("send unshared"); + assert!(ok.accepted, "unshared rejected: {}", ok.message); + let ok = author + .send_event(persona_event_with_shared(&author_keys, &d_shared, true)) + .await + .expect("send shared"); + assert!(ok.accepted, "shared rejected: {}", ok.message); + author.disconnect().await.expect("disconnect author"); + + // Foreign sends COUNT for {kinds:[30175], authors:[author]} — uses fallback path + // and must exclude the unshared event. + let mut foreign = BuzzTestClient::connect(&url, &foreign_keys) + .await + .expect("connect foreign"); + let sid = sub_id("count-persona"); + let filter = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()); + let count_msg = serde_json::json!(["COUNT", sid, filter]); + foreign.send_raw(&count_msg).await.expect("send COUNT"); + + // The relay returns ["COUNT", sub_id, {"count": N}]. + // buzz-ws-client parses this into RelayMessage::Count. + let result = foreign.recv_event(Duration::from_secs(5)).await; + let count: u64 = match result { + Ok(RelayMessage::Count { count, .. }) => count, + Ok(RelayMessage::Closed { message, .. }) => { + panic!("COUNT closed unexpectedly: {message}"); + } + Ok(other) => panic!("unexpected relay message for COUNT: {other:?}"), + Err(e) => panic!("unexpected error for COUNT: {e}"), + }; + + assert_eq!( + count, 1, + "foreign COUNT should see only the 1 shared persona, got {count}" + ); + + foreign.disconnect().await.expect("disconnect foreign"); +} + +/// AC-4a: Unshared persona publish is NOT delivered to foreign live subscription. +/// AC-4b: Shared persona publish IS delivered to foreign live subscription. +/// AC-4c: NIP-33 replace shared→unshared makes subsequent foreign REQs return nothing, +/// and the live subscription for the foreign reader receives no event. +/// +/// Uses explicit monotonic `created_at` timestamps so NIP-33 head ordering is +/// deterministic — same-second events would otherwise be ordered by event-id +/// tie-break, making it possible for the "wrong" head to win. +#[tokio::test] +#[ignore] +async fn test_persona_live_fanout_shared_gate() { + let url = relay_url(); + let author_keys = Keys::generate(); + let foreign_keys = Keys::generate(); + + let d_tag = format!("gate-{}", &uuid::Uuid::new_v4().to_string()[..8]); + + // Use strictly increasing timestamps relative to now: now-2 < now-1 < now. + // This satisfies the relay's timestamp-skew check while keeping NIP-33 + // ordering deterministic (t0 < t1 < t2 so same d-tag replacements always + // promote the highest timestamp regardless of event-id ordering). + let now = nostr::Timestamp::now().as_secs(); + let t0: u64 = now.saturating_sub(2); + let t1: u64 = now.saturating_sub(1); + let t2: u64 = now; + + // Foreign subscribes to the test author's kind:30175 events BEFORE the author + // publishes. Scoped to this author so concurrent persona tests publishing their + // own 30175s don't trip the leak-panic (parallel suite interference). + let mut foreign = BuzzTestClient::connect(&url, &foreign_keys) + .await + .expect("connect foreign"); + let sid = sub_id("fanout-gate"); + let filter = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()); + foreign + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + // Drain historical EOSE. + let _ = foreign + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("drain eose"); + + // Author publishes an UNSHARED persona (t0) — foreign must NOT receive it. + let mut author = BuzzTestClient::connect(&url, &author_keys) + .await + .expect("connect author"); + let ev_unshared = persona_event_with_shared_at(&author_keys, &d_tag, false, t0); + let unshared_id = ev_unshared.id; + let ok = author.send_event(ev_unshared).await.expect("send unshared"); + assert!(ok.accepted, "unshared rejected: {}", ok.message); + + tokio::time::sleep(Duration::from_millis(300)).await; + + // No event should arrive for foreign. + let result = foreign.recv_event(Duration::from_millis(500)).await; + match result { + Err(buzz_test_client::TestClientError::Timeout) => {} + Ok(RelayMessage::Event { event, .. }) if event.kind == Kind::Custom(PERSONA_KIND) => { + panic!( + "foreign MUST NOT receive unshared persona via live fan-out \ + (got event id={} author={})", + event.id, event.pubkey + ) + } + _ => {} + } + + // Verify the unshared event IS the NIP-33 head for the author (self-query). + let sid_head_check = sub_id("head-unshared"); + let filter_self = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()); + author + .subscribe(&sid_head_check, vec![filter_self]) + .await + .expect("subscribe head-check"); + let author_events = author + .collect_until_eose(&sid_head_check, Duration::from_secs(5)) + .await + .expect("collect head-check"); + assert!( + author_events.iter().any(|e| e.id == unshared_id), + "unshared event must be the NIP-33 head visible to author" + ); + + // Author republishes with ["shared","true"] at t1 — t1 > t0 so it wins. + // Foreign MUST receive it via live fan-out. + let ev_shared = persona_event_with_shared_at(&author_keys, &d_tag, true, t1); + let shared_id = ev_shared.id; + let ok = author.send_event(ev_shared).await.expect("send shared"); + assert!(ok.accepted, "shared rejected: {}", ok.message); + + tokio::time::sleep(Duration::from_millis(300)).await; + + let msg = foreign + .recv_event(Duration::from_secs(3)) + .await + .expect("recv shared event"); + match msg { + RelayMessage::Event { event, .. } => { + assert_eq!(event.id, shared_id, "received wrong event via fanout"); + } + other => panic!("expected shared persona event via fanout, got: {other:?}"), + } + + // Verify shared event is now the NIP-33 head (self-query sees shared_id). + let sid_head_shared = sub_id("head-shared"); + let filter_self2 = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()); + author + .subscribe(&sid_head_shared, vec![filter_self2]) + .await + .expect("subscribe head-shared"); + let author_events2 = author + .collect_until_eose(&sid_head_shared, Duration::from_secs(5)) + .await + .expect("collect head-shared"); + assert!( + author_events2.iter().any(|e| e.id == shared_id), + "shared event must be the NIP-33 head visible to author" + ); + + // AC-4c: Author republishes WITHOUT shared tag at t2 — NIP-33 replaces the head. + // The foreign live subscription must NOT receive any event for this publish + // (unshared fan-out is blocked), and a subsequent foreign REQ must return nothing. + let ev_unshared2 = persona_event_with_shared_at(&author_keys, &d_tag, false, t2); + let unshared2_id = ev_unshared2.id; + let ok = author + .send_event(ev_unshared2) + .await + .expect("send unshared2"); + assert!(ok.accepted, "unshared2 rejected: {}", ok.message); + + tokio::time::sleep(Duration::from_millis(300)).await; + + // Foreign live subscription must NOT receive the unshared replacement. + let live_result = foreign.recv_event(Duration::from_millis(500)).await; + match live_result { + Err(buzz_test_client::TestClientError::Timeout) => {} + Ok(RelayMessage::Event { event, .. }) if event.kind == Kind::Custom(PERSONA_KIND) => { + panic!( + "foreign MUST NOT receive unshared replacement via live fan-out \ + (got event id={} author={})", + event.id, event.pubkey + ) + } + _ => {} + } + + // Verify unshared2 is now the NIP-33 head for the author. + let sid_head_unshared2 = sub_id("head-unshared2"); + let filter_self3 = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()); + author + .subscribe(&sid_head_unshared2, vec![filter_self3]) + .await + .expect("subscribe head-unshared2"); + let author_events3 = author + .collect_until_eose(&sid_head_unshared2, Duration::from_secs(5)) + .await + .expect("collect head-unshared2"); + assert!( + author_events3.iter().any(|e| e.id == unshared2_id), + "unshared2 event must be the NIP-33 head visible to author" + ); + + // Foreign REQ post-unshare must see nothing for this author's personas. + let sid2 = sub_id("fanout-gate-post-unshare"); + let filter2 = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()); + foreign + .subscribe(&sid2, vec![filter2]) + .await + .expect("subscribe2"); + let events = foreign + .collect_until_eose(&sid2, Duration::from_secs(5)) + .await + .expect("collect post-unshare"); + assert!( + events.is_empty(), + "after removing shared tag, foreign REQ must return nothing, got {} events", + events.len() + ); + + author.disconnect().await.expect("disconnect author"); + foreign.disconnect().await.expect("disconnect foreign"); +} + +/// AC-5: Ingest rejects ["shared","false"], ["shared","x"], and duplicate shared tags. +/// Accepts ["shared","true"] and tag-absent. +#[tokio::test] +#[ignore] +async fn test_persona_ingest_shared_tag_validation() { + let url = relay_url(); + let keys = Keys::generate(); + + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + + // Accept: no shared tag + let ev = persona_event_with_shared( + &keys, + &format!("no-shared-{}", &uuid::Uuid::new_v4().to_string()[..8]), + false, + ); + let ok = client.send_event(ev).await.expect("send no-shared"); + assert!( + ok.accepted, + "no-shared-tag persona must be accepted: {}", + ok.message + ); + + // Accept: ["shared","true"] + let ev = persona_event_with_shared( + &keys, + &format!("shared-true-{}", &uuid::Uuid::new_v4().to_string()[..8]), + true, + ); + let ok = client.send_event(ev).await.expect("send shared-true"); + assert!( + ok.accepted, + "shared=true persona must be accepted: {}", + ok.message + ); + + // Reject: ["shared","false"] + let ev = EventBuilder::new(Kind::Custom(PERSONA_KIND), r#"{"display_name":"x"}"#) + .tags(vec![ + Tag::parse([ + "d", + &format!("shared-false-{}", &uuid::Uuid::new_v4().to_string()[..8]), + ]) + .unwrap(), + Tag::parse(["shared", "false"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let ok = client.send_event(ev).await.expect("send shared-false"); + assert!(!ok.accepted, "shared=false persona must be rejected"); + assert!( + ok.message.contains("shared") || ok.message.contains("invalid"), + "unexpected rejection message: {}", + ok.message + ); + + // Reject: ["shared","x"] — any value other than "true" is malformed. + let ev = EventBuilder::new(Kind::Custom(PERSONA_KIND), r#"{"display_name":"x"}"#) + .tags(vec![ + Tag::parse([ + "d", + &format!("shared-x-{}", &uuid::Uuid::new_v4().to_string()[..8]), + ]) + .unwrap(), + Tag::parse(["shared", "x"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let ok = client.send_event(ev).await.expect("send shared-x"); + assert!( + !ok.accepted, + "shared=x persona must be rejected: {}", + ok.message + ); + + // Reject: ["shared"] — value is missing (tag has only 1 element after the key). + let ev = EventBuilder::new(Kind::Custom(PERSONA_KIND), r#"{"display_name":"x"}"#) + .tags(vec![ + Tag::parse([ + "d", + &format!("shared-no-value-{}", &uuid::Uuid::new_v4().to_string()[..8]), + ]) + .unwrap(), + Tag::parse(["shared"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let ok = client.send_event(ev).await.expect("send shared-no-value"); + assert!( + !ok.accepted, + "shared tag with missing value must be rejected: {}", + ok.message + ); + + // Reject: duplicate shared tags + let ev = EventBuilder::new(Kind::Custom(PERSONA_KIND), r#"{"display_name":"x"}"#) + .tags(vec![ + Tag::parse([ + "d", + &format!("dup-shared-{}", &uuid::Uuid::new_v4().to_string()[..8]), + ]) + .unwrap(), + Tag::parse(["shared", "true"]).unwrap(), + Tag::parse(["shared", "true"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let ok = client.send_event(ev).await.expect("send dup-shared"); + assert!(!ok.accepted, "duplicate shared tags must be rejected"); + + client.disconnect().await.expect("disconnect"); +} + +/// AC-6: Mixed-kind filter {kinds:[30175, 9]} does not leak foreign unshared personas, +/// but DOES pass through kind:9 events from the same author. This pins the +/// correctness property in both directions — an implementation that silently +/// drops the whole mixed-kind filter would satisfy only the absence assertion. +#[tokio::test] +#[ignore] +async fn test_persona_mixed_kind_filter_does_not_leak() { + let url = relay_url(); + let author_keys = Keys::generate(); + let foreign_keys = Keys::generate(); + + let d_tag = format!("mixed-{}", &uuid::Uuid::new_v4().to_string()[..8]); + + // Create an open-visibility channel so the kind:9 control event is accessible + // to the foreign reader without an explicit membership entry. + let channel_id = create_test_channel(&author_keys).await; + + // Author publishes an unshared persona AND a kind:9 message in the open channel. + let mut author = BuzzTestClient::connect(&url, &author_keys) + .await + .expect("connect author"); + let ev = persona_event_with_shared(&author_keys, &d_tag, false); + let unshared_id = ev.id; + let ok = author.send_event(ev).await.expect("send unshared"); + assert!(ok.accepted, "unshared persona rejected: {}", ok.message); + + // Publish a kind:9 event in the open channel so the mixed-kind filter has + // something to return and we can assert the persona gate is per-event, not + // a wholesale filter drop. + let ev9 = EventBuilder::new(Kind::Custom(9), "hello from author") + .tags(vec![Tag::parse(["h", &channel_id]).unwrap()]) + .sign_with_keys(&author_keys) + .unwrap(); + let msg9_id = ev9.id; + let ok9 = author.send_event(ev9).await.expect("send kind:9"); + assert!(ok9.accepted, "kind:9 rejected: {}", ok9.message); + + author.disconnect().await.expect("disconnect author"); + + // Foreign queries with mixed-kind filter. + let mut foreign = BuzzTestClient::connect(&url, &foreign_keys) + .await + .expect("connect foreign"); + let sid = sub_id("mixed-kind"); + let filter = Filter::new() + .kinds(vec![Kind::Custom(PERSONA_KIND), Kind::Custom(9)]) + .author(author_keys.public_key()); + foreign + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + let events = foreign + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + // Foreign must NOT see the unshared persona. + assert!( + !events.iter().any(|e| e.id == unshared_id), + "mixed-kind filter must NOT leak foreign unshared persona (id {})", + unshared_id + ); + // Foreign MUST see the kind:9 event — filter is not wholesale dropped. + assert!( + events.iter().any(|e| e.id == msg9_id), + "mixed-kind filter must pass through kind:9 events (id {})", + msg9_id + ); + + foreign.disconnect().await.expect("disconnect foreign"); +} + +// ─── NIP-98 HTTP bridge persona gate tests ─────────────────────────────────── +// +// These tests verify that `POST /query` and `POST /count` enforce the same +// author-only-unless-shared gate as the WebSocket paths. A foreign +// authenticated caller must not receive or count unshared kind:30175 events +// belonging to another author, even via the HTTP bridge. + +/// NIP-98 bridge AC-query: `/query` cross-author gate. +/// +/// - A foreign pubkey posting `{kinds:[30175],authors:[victim]}` receives only +/// the shared head, not the unshared one. +/// - A kindless `{ids:[unshared-id]}` returns nothing. +/// - A `{ids:[shared-id]}` returns the shared event. +#[tokio::test] +#[ignore] +async fn test_persona_http_query_cross_author_gate() { + let client = http_client(); + let author_keys = Keys::generate(); + let foreign_pubkey_hex = Keys::generate().public_key().to_hex(); + let author_pubkey_hex = author_keys.public_key().to_hex(); + + let d_unshared = format!("priv-http-{}", &uuid::Uuid::new_v4().to_string()[..8]); + let d_shared = format!("pub-http-{}", &uuid::Uuid::new_v4().to_string()[..8]); + + // Publish via HTTP bridge (ingest path is the same). + let ev_unshared = persona_event_with_shared(&author_keys, &d_unshared, false); + let unshared_id_hex = ev_unshared.id.to_hex(); + let ev_shared = persona_event_with_shared(&author_keys, &d_shared, true); + let shared_id_hex = ev_shared.id.to_hex(); + + let (ok, msg) = submit_event_http(&client, &author_keys, &ev_unshared).await; + assert!(ok, "unshared ingest rejected: {msg}"); + let (ok, msg) = submit_event_http(&client, &author_keys, &ev_shared).await; + assert!(ok, "shared ingest rejected: {msg}"); + + // Foreign queries all author's kind:30175 — only shared must come back. + let filter = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()); + let results = query_events_http(&client, &foreign_pubkey_hex, vec![filter]).await; + + let has_unshared = results.iter().any(|e| { + e.get("id") + .and_then(|v| v.as_str()) + .is_some_and(|id| id == unshared_id_hex) + }); + let has_shared = results.iter().any(|e| { + e.get("id") + .and_then(|v| v.as_str()) + .is_some_and(|id| id == shared_id_hex) + }); + + assert!( + !has_unshared, + "/query (authors:[victim]) must NOT return unshared persona to foreign" + ); + assert!( + has_shared, + "/query (authors:[victim]) must return shared persona to foreign" + ); + + // Author self-query must see both. + let filter_self = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()); + let self_results = query_events_http(&client, &author_pubkey_hex, vec![filter_self]).await; + assert!( + self_results.iter().any(|e| e + .get("id") + .and_then(|v| v.as_str()) + .is_some_and(|id| id == unshared_id_hex)), + "author self-query must see own unshared persona" + ); + assert!( + self_results.iter().any(|e| e + .get("id") + .and_then(|v| v.as_str()) + .is_some_and(|id| id == shared_id_hex)), + "author self-query must see own shared persona" + ); + + // Kindless ids lookup for unshared event: foreign must get nothing. + let unshared_event_id = nostr::EventId::from_hex(&unshared_id_hex).unwrap(); + let filter_ids = Filter::new().id(unshared_event_id); + let id_results = query_events_http(&client, &foreign_pubkey_hex, vec![filter_ids]).await; + assert!( + id_results.is_empty(), + "/query {{ids:[unshared-id]}} must return nothing to foreign, got {}", + id_results.len() + ); + + // Kindless ids lookup for shared event: foreign must get it. + let shared_event_id = nostr::EventId::from_hex(&shared_id_hex).unwrap(); + let filter_shared_ids = Filter::new().id(shared_event_id); + let shared_id_results = + query_events_http(&client, &foreign_pubkey_hex, vec![filter_shared_ids]).await; + assert!( + shared_id_results.iter().any(|e| e + .get("id") + .and_then(|v| v.as_str()) + .is_some_and(|id| id == shared_id_hex)), + "/query {{ids:[shared-id]}} must return shared event to foreign" + ); +} + +/// NIP-98 bridge AC-count: `/count` cross-author gate. +/// +/// A foreign authenticated caller counting `{kinds:[30175],authors:[victim]}` +/// must count only shared heads — not unshared ones — on both the fast SQL +/// path (prevented by `needs_persona_filtering`) and the fallback path. +#[tokio::test] +#[ignore] +async fn test_persona_http_count_cross_author_gate() { + let client = http_client(); + let author_keys = Keys::generate(); + let foreign_pubkey_hex = Keys::generate().public_key().to_hex(); + let author_pubkey_hex = author_keys.public_key().to_hex(); + + // Publish two unshared + one shared persona for the author. + let d1 = format!("priv1-cnt-{}", &uuid::Uuid::new_v4().to_string()[..8]); + let d2 = format!("priv2-cnt-{}", &uuid::Uuid::new_v4().to_string()[..8]); + let d_shared = format!("pub-cnt-{}", &uuid::Uuid::new_v4().to_string()[..8]); + + for (d, shared) in [(&d1, false), (&d2, false), (&d_shared, true)] { + let ev = persona_event_with_shared(&author_keys, d, shared); + let (ok, msg) = submit_event_http(&client, &author_keys, &ev).await; + assert!(ok, "ingest rejected for {d}: {msg}"); + } + + // Foreign counts author's personas — must see only 1 (the shared one). + let filter = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()); + let foreign_count = count_events_http(&client, &foreign_pubkey_hex, vec![filter]) + .await + .expect("count should succeed"); + assert_eq!( + foreign_count, 1, + "foreign /count should return 1 (only shared persona), got {foreign_count}" + ); + + // Author self-count must see all 3. + let filter_self = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()); + let author_count = count_events_http(&client, &author_pubkey_hex, vec![filter_self]) + .await + .expect("author count should succeed"); + assert_eq!( + author_count, 3, + "author /count should return 3, got {author_count}" + ); + + // Wildcard count (no authors filter) for the foreign reader — must not + // include foreign unshared personas in the total. + let filter_wildcard = Filter::new().kind(Kind::Custom(PERSONA_KIND)); + let wildcard_count = count_events_http(&client, &foreign_pubkey_hex, vec![filter_wildcard]) + .await + .expect("wildcard count should succeed"); + // We can't assert the exact number (other tests may have published shared + // personas), but we can assert it's ≥ 1 (the shared one) and verify the + // unshared ones aren't counted by checking author-scoped again. + assert!( + wildcard_count >= 1, + "wildcard count must include the shared persona" + ); + // The foreign author-scoped count must still be exactly 1. + let filter_scoped = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()); + let scoped_count2 = count_events_http(&client, &foreign_pubkey_hex, vec![filter_scoped]) + .await + .expect("scoped count2 should succeed"); + assert_eq!( + scoped_count2, 1, + "foreign author-scoped /count must still return 1 after wildcard query, got {scoped_count2}" + ); +} + +/// Task-1 regression: WS REQ visibility-before-LIMIT gate. +/// +/// Publishes N unshared (newer) personas followed by 1 shared (older) persona, +/// then queries with `limit < N`. Without the SQL-level visibility clause, the +/// page is filled with unshared events and the shared one never appears. With +/// the clause, unshared events are excluded before ORDER/LIMIT so the shared +/// event is returned. +/// +/// Verifies at `312014d5e`: this test fails there because `query_events` did +/// not have the `persona_reader` SQL clause and the private rows starved the +/// shared one off the page. +#[tokio::test] +#[ignore] +async fn test_persona_ws_req_shared_visible_with_newer_private_ahead() { + let url = relay_url(); + let author_keys = Keys::generate(); + let foreign_keys = Keys::generate(); + + // Publish N=3 private personas with the highest timestamps, then 1 shared + // with the lowest timestamp. limit=2 means the first page has only 2 + // candidates in the old (no-SQL-clause) world — both private — and the + // shared one is invisible. + let now = nostr::Timestamp::now().as_secs(); + let shared_ts = now.saturating_sub(10); + + let mut author = BuzzTestClient::connect(&url, &author_keys) + .await + .expect("connect author"); + + // Publish 3 private personas at t=now, now-1, now-2 (all newer than shared). + for i in 0..3u64 { + let d = format!( + "priv-limit-{}-{}", + i, + &uuid::Uuid::new_v4().to_string()[..8] + ); + let ev = persona_event_with_shared_at(&author_keys, &d, false, now.saturating_sub(i)); + let ok = author.send_event(ev).await.expect("send private"); + assert!(ok.accepted, "private persona {i} rejected: {}", ok.message); + } + + // Publish 1 shared persona at a lower timestamp (older than all private ones). + let shared_d = format!("shared-limit-{}", &uuid::Uuid::new_v4().to_string()[..8]); + let ev_shared = persona_event_with_shared_at(&author_keys, &shared_d, true, shared_ts); + let shared_id = ev_shared.id; + let ok = author.send_event(ev_shared).await.expect("send shared"); + assert!(ok.accepted, "shared persona rejected: {}", ok.message); + author.disconnect().await.expect("disconnect author"); + + // Foreign queries with limit=2: private rows are excluded at SQL level, + // so the shared event must appear despite having a lower timestamp. + let mut foreign = BuzzTestClient::connect(&url, &foreign_keys) + .await + .expect("connect foreign"); + let sid = sub_id("limit-gate"); + let filter = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()) + .limit(2); + foreign + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + let events = foreign + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert!( + events.iter().any(|e| e.id == shared_id), + "shared persona must appear even when newer private personas fill the LIMIT (got {} events)", + events.len() + ); + assert!( + events.iter().all(|e| { + e.tags + .iter() + .any(|t| t.as_slice().first().is_some_and(|v| v.as_str() == "shared")) + }), + "foreign must NOT see any unshared persona" + ); + + foreign.disconnect().await.expect("disconnect foreign"); +} + +/// Task-1 regression: HTTP `/query` visibility-before-LIMIT gate. +/// +/// Same scenario as the WS variant above, over the NIP-98 HTTP bridge. +#[tokio::test] +#[ignore] +async fn test_persona_http_query_shared_visible_with_newer_private_ahead() { + let client = http_client(); + let author_keys = Keys::generate(); + let foreign_pubkey_hex = Keys::generate().public_key().to_hex(); + + let now = nostr::Timestamp::now().as_secs(); + let shared_ts = now.saturating_sub(10); + + // Publish 3 private (newer) + 1 shared (older). + for i in 0..3u64 { + let d = format!( + "http-priv-limit-{}-{}", + i, + &uuid::Uuid::new_v4().to_string()[..8] + ); + let ev = persona_event_with_shared_at(&author_keys, &d, false, now.saturating_sub(i)); + let (ok, msg) = submit_event_http(&client, &author_keys, &ev).await; + assert!(ok, "private persona {i} rejected: {msg}"); + } + + let shared_d = format!( + "http-shared-limit-{}", + &uuid::Uuid::new_v4().to_string()[..8] + ); + let ev_shared = persona_event_with_shared_at(&author_keys, &shared_d, true, shared_ts); + let shared_id_hex = ev_shared.id.to_hex(); + let (ok, msg) = submit_event_http(&client, &author_keys, &ev_shared).await; + assert!(ok, "shared persona rejected: {msg}"); + + // Foreign /query with limit=2: shared event must appear. + let filter = serde_json::json!({ + "kinds": [PERSONA_KIND], + "authors": [author_keys.public_key().to_hex()], + "limit": 2 + }); + let resp = client + .post(format!("{}/query", relay_http_url())) + .header("X-Pubkey", &foreign_pubkey_hex) + .header("Content-Type", "application/json") + .json(&vec![filter]) + .send() + .await + .expect("query"); + assert!( + resp.status().is_success(), + "query failed: {}", + resp.status() + ); + let results: Vec = resp.json().await.expect("parse"); + + let has_shared = results.iter().any(|e| { + e.get("id") + .and_then(|v| v.as_str()) + .is_some_and(|id| id == shared_id_hex) + }); + assert!( + has_shared, + "shared persona must appear in /query result even with newer private ones ahead (got {} events)", + results.len() + ); +} + +/// Task-2 wire-level: ingest must reject ["shared","true","extra"] over the wire. +/// +/// The unit tests verify the validator directly; this test confirms the rejection +/// propagates end-to-end through the WebSocket ingest path. +#[tokio::test] +#[ignore] +async fn test_persona_ingest_rejects_three_element_shared_tag() { + let url = relay_url(); + let keys = Keys::generate(); + + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + + // Build a persona event with a three-element ["shared","true","extra"] tag. + // nostr::Tag::parse accepts variable-length slices, so this is straightforward. + let ev = EventBuilder::new(Kind::Custom(PERSONA_KIND), r#"{"display_name":"x"}"#) + .tags(vec![ + Tag::parse([ + "d", + &format!("extra-{}", &uuid::Uuid::new_v4().to_string()[..8]), + ]) + .unwrap(), + Tag::parse(["shared", "true", "extra"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + + let ok = client + .send_event(ev) + .await + .expect("send three-element shared tag"); + assert!( + !ok.accepted, + "three-element [\"shared\",\"true\",\"extra\"] tag must be rejected at ingest: {}", + ok.message + ); + assert!( + ok.message.contains("[\"shared\",\"true\"]") || ok.message.contains("shared"), + "rejection message should reference the shared tag constraint, got: {}", + ok.message + ); + + client.disconnect().await.expect("disconnect"); +} diff --git a/crates/buzz-ws-client/src/message.rs b/crates/buzz-ws-client/src/message.rs index 4950c117d6..3c646bc8ab 100644 --- a/crates/buzz-ws-client/src/message.rs +++ b/crates/buzz-ws-client/src/message.rs @@ -37,6 +37,13 @@ pub enum RelayMessage { /// The challenge string to sign. challenge: String, }, + /// A NIP-45 COUNT response. + Count { + /// The subscription ID this count belongs to. + subscription_id: String, + /// The number of matching events. + count: u64, + }, } /// The relay's response to a published event (NIP-01 `OK` message). @@ -137,6 +144,22 @@ pub fn parse_relay_message(text: &str) -> Result { .to_string(); Ok(RelayMessage::Auth { challenge }) } + "COUNT" => { + let sub_id = arr + .get(1) + .and_then(|v| v.as_str()) + .ok_or_else(|| WsClientError::UnexpectedMessage(text.to_string()))? + .to_string(); + let count = arr + .get(2) + .and_then(|o| o.get("count")) + .and_then(|c| c.as_u64()) + .ok_or_else(|| WsClientError::UnexpectedMessage(text.to_string()))?; + Ok(RelayMessage::Count { + subscription_id: sub_id, + count, + }) + } other => Err(WsClientError::UnexpectedMessage(format!( "unknown message type: {other}" ))), diff --git a/docs/nips/NIP-AP.md b/docs/nips/NIP-AP.md index 85690075de..b990f55bef 100644 --- a/docs/nips/NIP-AP.md +++ b/docs/nips/NIP-AP.md @@ -218,15 +218,47 @@ Agents spawned from a persona carry [NIP-OA](NIP-OA.md) owner attestation — an ## Relay behavior +### Ingest validation + - The relay MUST accept `kind:30175` events that pass standard NIP-33 validation (valid signature, exactly one `d` tag with a non-empty value). - The relay stores persona events globally (`channel_id = NULL`); they are not channel-scoped. - The relay is NOT required to validate that `content` parses as valid `PersonaEventContent` JSON. Relays are dumb stores per Nostr convention; content validation is a client responsibility. - The relay MUST enforce that the `d` tag is non-empty (standard NIP-33 requirement for parameterized replaceable events). +- The relay MUST enforce shared-tag shape: if a `shared` tag is present, it MUST consist of **exactly two elements** — `["shared", "true"]`. Extra elements (e.g. `["shared","true","extra"]`), wrong values (`["shared","false"]`), missing values (`["shared"]`), or duplicate `shared` tags are all rejected with `invalid:`. The two-element exact-shape constraint is required so that the relay's SQL visibility clause (`tags @> '[["shared","true"]]'`) never matches a stored malformed tag via JSONB containment supersets. + +### Access control: author-only-unless-shared + +Kind `30175` uses **shared-tag-gated read semantics** to protect system prompts and `respond_to_allowlist` from being visible to all community members as a side-effect of device sync. + +**Rules:** + +| Event state | Author reads | Foreign reads | +|---|---|---| +| No `shared` tag | ✅ allowed | ❌ withheld | +| `["shared", "true"]` tag | ✅ allowed | ✅ allowed | + +These rules are enforced at the following relay read surfaces (content and event existence are withheld on all of them): + +- **REQ historical delivery** — foreign requests silently omit unshared persona events, even in mixed-kind filters (`{kinds:[30175,9]}`). The visibility check is applied **before `ORDER BY … LIMIT`** at the SQL level (`persona_reader` field in `EventQuery`), so a page of newer private personas cannot starve an older shared persona off the candidate set — the catalog's primary all-author query pattern is correctly served. +- **NIP-01 `ids` lookup** — knowing an event id does NOT grant access to an unshared persona. The result gate returns nothing. +- **Live fan-out** — unshared personas are delivered only to the author's connections. Shared personas fan out community-wide. +- **COUNT** — the fast SQL `count_events()` path is bypassed when the filter can match `kind:30175`. A per-event fallback applies the shared-tag check, preventing existence-leak via COUNT. +- **NIP-98 HTTP bridge `/query`** — the same per-event visibility check is applied to the catchall post-processing loop. The SQL-level `persona_reader` clause also applies before `LIMIT`, preventing older shared personas from being starved by newer private ones on paginated catalog queries. A foreign caller POSTing `{kinds:[30175],authors:[victim]}` or a kindless `{ids:[...]}` filter to `/query` receives no unshared persona content. +- **NIP-98 HTTP bridge `/count`** — `needs_persona_filtering` forces the per-event fallback path for any filter that can match `kind:30175`; the fast SQL `count_events()` path is not used. Both the channel-scoped and unconstrained fallback loops apply `event_visible_to_reader`, preventing existence-leak via COUNT over HTTP. +- **FTS (NIP-50 search) and `/search`** — kind `30175` is not in the relay's FTS allowlist (migration 8 indexes only kinds `0, 9, 40002, 45001, 45003`); no FTS result can contain an unshared persona. A defense-in-depth check is also present in the bridge search result loop so that a future FTS allowlist change cannot silently reopen the bypass. + +**Device sync is unaffected.** The sync subscription (`{kinds:[30175], authors:[self]}`) reads the author's own events, which are always returned regardless of shared state. + +**Opting in to community sharing.** Publish a NIP-33 replacement head for the persona with a `["shared", "true"]` tag. Unsharing is the reverse: republish without the tag. NIP-33 replacement semantics apply (newest `created_at` wins). + +**`shared` is a tag, not a content field.** Content bytes are hash-pinned as the NIP-01 event id and also used as the `source_version` for persona drift detection. A content-field toggle would look like a definition edit; a tag does not affect content bytes. + +**Non-goal: side-band existence oracles.** Reaction, report, and event-deletion validation resolves target events by id to check that they exist. These paths intentionally accept arbitrary event references by design — they leak one bit (existence) but never content, and exploiting them requires already possessing a 64-hex event id that unshared personas never expose through any gated read path. Gating these side-band resolvers would require teaching reaction/report validation about persona read semantics with no realistic attack mitigated. If a stricter "zero existence leakage" property is required in future, it is a separate scoped task. ## Security considerations -- **No encryption.** System prompts, model names, runtime identifiers, and all configuration are visible to anyone with relay read access. Operators MUST NOT store secrets in persona event content. -- **System prompt sensitivity.** System prompts may contain security-relevant behavioral instructions. Publishing them unencrypted enables adversarial prompt extraction. Operators who consider system prompts confidential SHOULD NOT publish them in persona events, or SHOULD use a relay with appropriate access controls. +- **No encryption.** System prompts, model names, runtime identifiers, and all configuration are stored unencrypted. Shared persona events are readable community-wide. Operators MUST NOT store secrets in persona event content. +- **System prompt protection.** System prompts and `respond_to_allowlist` pubkeys are sensitive. The relay's author-only-unless-shared gate ensures they are not visible to other community members unless the owner explicitly opts in by publishing a `["shared", "true"]` head. Shared persona events are readable community-wide; operators who need additional confidentiality should use relay-level access controls or choose not to share. - **Write authority.** Only the holder of `seckey_o` can publish or replace persona events. NIP-33 replacement is scoped by pubkey — no spoofing risk from other relay members. - **Slug collision across pubkeys.** Two different owners can publish personas with the same slug. Clients MUST always scope queries by author pubkey, not just slug. - **Metadata exposure.** The `(pubkey, kind:30175, slug)` triple reveals persona existence. Event timestamps reveal edit history.