mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(relay): add author-only-unless-shared read gate for kind 30175 (#2768)
Kind 30175 persona sync events carry plaintext `system_prompt` and
`respond_to_allowlist`. This PR adds **author-only-unless-shared read
semantics**: events without `["shared","true"]` are visible only to the
author; events with that tag are community-readable.
## What changed
### New read class (kind 30175)
Kind 30175 gets per-event gating at every relay read surface. The
`shared` marker is a **tag**, not a content field, so content bytes
(which double as the `source_version` drift basis) are not affected when
toggling share state.
### `event_visible_to_reader` helper (`handlers/req.rs`)
Centralizes the three per-event access predicates —
`is_author_only_event`, `is_unshared_persona_event`,
`reader_authorized_for_event` — into one `pub(crate)` fn callable from
both WS and HTTP adapters. All result-visibility sites now call this
single helper.
### NIP-98 HTTP bridge (`api/bridge.rs`)
- `POST /query` catchall: replaced the two-step author-only +
result-gated checks with `event_visible_to_reader` (now also covers the
persona shared-gate).
- `POST /count`: added `needs_persona_filtering` to the fast-path guard
(forces per-event fallback when filter can match `kind:30175`) and
replaced both fallback loops' individual checks with
`event_visible_to_reader`.
- FTS `/search` bridge helper: replaced `is_author_only_event` with
`event_visible_to_reader` as defense-in-depth (30175 is not in the FTS
allowlist today; comment at site explains the future-proofing intent).
### Ingest validation (`handlers/ingest.rs`)
`validate_persona_envelope` rejects malformed `shared` tags: wrong
value, missing value, duplicates. Accepts exactly `["shared","true"]`
and tag-absent.
### Kind helpers (`buzz-core/src/kind.rs`)
`is_persona_shared_kind`, `is_unshared_persona_event`,
`filter_can_match_persona_shared_kinds`.
### Tests (`e2e_persona.rs`)
8 unit tests in `kind.rs`, 6 in `ingest.rs`, 8 e2e tests total:
- AC-1–6 covering the gated surfaces
- `test_persona_live_fanout_shared_gate`: reworked with explicit
monotonic `created_at` timestamps (t0 < t1 < t2) and per-step head
assertions, eliminating the NIP-33 event-id tie-break race. Also asserts
foreign live subscription receives nothing on shared→unshared
transition.
- `test_persona_ingest_shared_tag_validation`: added `shared=x` and
missing-value wire-level rejection cases.
- `test_persona_mixed_kind_filter_does_not_leak`: publishes a kind-9
event and asserts it IS returned; absence-only assertion no longer
sufficient.
- `test_persona_http_query_cross_author_gate`: NIP-98 `/query`
cross-author gate (authors filter, kindless `ids` — both blocked; shared
`ids` — passes).
- `test_persona_http_count_cross_author_gate`: NIP-98 `/count`
cross-author gate (foreign sees 1/shared, author sees all, wildcard
checked).
### NIP-AP.md
Replaced aspirational "every relay read chokepoint" wording with an
enumerated list of gated surfaces including NIP-98 `/query`, `/count`,
and FTS/search with their enforcement mechanism named. Added
**Non-goal** note for side-band existence oracles
(reaction/report/deletion target resolution).
## Existing tests
All pre-existing `e2e_persona` tests use `{ids:[event_id]}` or
`{authors:[self]}` filters — author self-reads bypass the gate and are
unaffected.
## Gates
`just check` ✅ | `just test-unit` ✅ | `cargo test -p buzz-relay` ✅ (749
passed, 1 pre-existing failure in
`demo_join_forwarded_arm_round_trips_echo` — flaky on `main`, unrelated
to this PR, verified red at `origin/main` before this branch)
---------
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -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<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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -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<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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
));
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -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<nostr::EventId> = 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<uuid::Uuid>, 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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<RelayMessage, WsClientError> {
|
||||
.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}"
|
||||
))),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user