feat(relay): gate kind 30178 team-catalog reads behind the shared tag (#3358)

Team catalog projections (`kind:30178`) embed every member's system
prompt, so they need the same read gate personas already have: only the
author sees an unshared event. The gate was hardcoded to `kind:30175` at
six read surfaces plus the SQL pushdown, so rather than adding a second
special case it becomes kind-generic over `SHARED_GATED_KINDS = {30175,
30178}`.

## Kind 30178

New parameterized-replaceable kind, addressed by `(pubkey_o, 30178,
team_id)`. It embeds sanitized member projections instead of referencing
`kind:30175` heads — a foreign reader of a shared team could not
otherwise hydrate members whose own persona events are unshared or, for
built-ins, absent entirely. `kind:30176`'s wire body is untouched, so
device sync keeps its contract.

## Kind-generic shared gate

`buzz_core::kind` replaces `is_persona_shared_kind` /
`is_unshared_persona_event` / `persona_event_is_shared` with
`SHARED_GATED_KINDS` and the kind-agnostic `is_shared_gated_kind` /
`is_unshared_gated_event` / `event_is_shared`. Every read surface
consults the set:

| Surface | File |
|---|---|
| REQ historical delivery + `ids` lookup |
`crates/buzz-relay/src/handlers/req.rs` |
| Live fan-out | `crates/buzz-relay/src/handlers/event.rs` |
| COUNT fallback | `crates/buzz-relay/src/handlers/count.rs` |
| NIP-98 HTTP `/query`, `/count`, `/search` |
`crates/buzz-relay/src/api/bridge.rs` |
| Pre-`LIMIT` SQL pushdown | `crates/buzz-db/src/event.rs` |

The SQL clause generalizes from `kind != 30175` to `kind NOT IN (...)`
bound from `SHARED_GATED_KINDS`, still applied before `ORDER BY … LIMIT`
so a page of newer private events cannot starve an older shared one off
the candidate set. `EventQuery::persona_reader` is renamed
`shared_gated_reader` and `needs_persona_filtering` to
`needs_shared_gate_filtering` to match.

Because the `buzz-core` rename has consumers outside the relay, the four
desktop call sites of `persona_event_is_shared` travel with it:
`desktop/src-tauri/src/commands/personas/pending.rs`,
`desktop/src-tauri/src/event_sync.rs`, and two in
`desktop/src-tauri/src/managed_agents/persona_events.rs`. Each call is
unchanged apart from the name — the persona `shared` projection behaves
exactly as before.

## Ingest validation

`validate_persona_envelope` splits into two reusable pieces —
`validate_shared_tag` (exactly-two-element `["shared","true"]`, at most
one occurrence) and `single_bounded_d_tag` (exactly one `d` tag,
non-empty, `<=64` chars, no ASCII control characters or whitespace).
`validate_team_catalog_envelope` composes both; personas additionally
keep the slug grammar `^[a-z0-9][a-z0-9_-]{0,63}$`.

`kind:30178` deliberately does **not** get the slug grammar. Team ids
are UUIDs or built-in identifiers such as `builtin-team:welcome`, and
the colon is not slug-legal; rewriting ids to fit would break NIP-33
addressing against the team's own `kind:30176` head. The non-empty and
exactly-one checks are load-bearing regardless — without them generic
NIP-33 storage maps a missing `d` onto `(pubkey_o, 30178, "")` and every
team overwrites its predecessor.

The exact two-element `shared` shape is enforced because the SQL
visibility clause is JSONB containment (`tags @>
'[["shared","true"]]'`), which would match a three-element superset such
as `["shared","true","extra"]`.

`kind:30178` is also added to the `Scope::UsersWrite` allowlist and to
`is_global_only_kind`, so a stray `h` tag cannot channel-scope an
owner-authored definition.

## Deferred

`kind:30176` is deliberately not a gate member. Its writers never emit
`shared`, so catalog opt-in semantics do not describe it — it needs
owner-private reads driven by an authenticated principal set, tracked as
a separate follow-up.

## Tests

- 19 new `ingest.rs` unit tests covering the 30178 envelope (UUID and
colon `d` tags, 64-char boundary, non-ASCII bound,
empty/valueless/duplicate/missing `d`, embedded newline, `shared`
false/three-element/duplicate, scope and global-only membership).
- Persona regressions for the valueless `["d"]` shapes, since the
`d`-tag helper is shared by both validators.
- Existing `kind.rs` gate tests generalized and extended to assert the
gate applies to 30178 as it does to 30175.
- New `crates/buzz-test-client/tests/e2e_team_catalog.rs`: 9 WS-level
tests over a live relay covering author reads of unshared heads, foreign
omission from REQ, `ids`-lookup denial, COUNT existence-leak, share and
unshare transitions, and the mixed-kind filter case.
- `.github/workflows/ci.yml` adds `--test e2e_team_catalog` to the Relay
E2E job so the new suite runs.

## Docs

`docs/nips/NIP-AP.md` gains a "Team catalog projection: kind:30178"
section and an "Ingest validation: kind:30178" subsection, records the
gate as kind-generic, documents 30178 deletion vs. unshare semantics,
and adds a security note that sharing a team exposes every member's
instructions even when that member's own `kind:30175` head is unshared.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Will Pfleger
2026-07-30 17:53:30 -04:00
committed by GitHub
parent b9e4ed616f
commit 114d40d9d3
14 changed files with 1033 additions and 179 deletions

View File

@@ -739,7 +739,7 @@ jobs:
./scripts/start-relay-for-tests.sh --no-build
- name: Relay E2E tests
run: |
cargo test -p buzz-test-client --test e2e_persona --test e2e_nostr_interop -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture
env:

View File

@@ -182,29 +182,43 @@ pub const P_GATED_KINDS: &[u32] = &[
/// 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).
/// Kinds that use the author-only-unless-shared read model.
///
/// 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
/// event carries exactly `["shared", "true"]`. Every relay read chokepoint
/// consults this set: REQ historical delivery, live fan-out, COUNT fallback,
/// the `ids`-lookup result gate, both HTTP surfaces, and the pre-`LIMIT` SQL
/// visibility pushdown in `buzz-db`.
///
/// Membership is a privacy decision, not a convenience: adding a kind here
/// makes its events invisible to foreign readers until their author opts in,
/// and the opt-in must be a `shared` TAG (not a content field) so that
/// toggling it leaves content bytes — and any content hash derived from them —
/// unchanged.
///
/// `KIND_TEAM` (30176) is deliberately NOT a member. Its writers never emit
/// `shared`, so catalog opt-in semantics do not describe it; it needs
/// owner-private read semantics instead, which is a separate change.
pub const SHARED_GATED_KINDS: &[u32] = &[KIND_PERSONA, KIND_TEAM_CATALOG];
/// Returns `true` if `kind` uses the author-only-unless-shared read model
/// (see [`SHARED_GATED_KINDS`]).
pub fn is_shared_gated_kind(kind: u32) -> bool {
SHARED_GATED_KINDS.contains(&kind)
}
/// 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.
/// Returns `true` if the event is a shared-gated 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
/// `is_author_only_event` — shared-gated 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 {
pub fn is_unshared_gated_event(event: &nostr::Event, requester_pubkey_bytes: &[u8]) -> bool {
let kind = event.kind.as_u16() as u32;
if !is_persona_shared_kind(kind) {
if !is_shared_gated_kind(kind) {
return false;
}
// Author reads are always allowed.
@@ -212,18 +226,23 @@ pub fn is_unshared_persona_event(event: &nostr::Event, requester_pubkey_bytes: &
return false;
}
// Foreign reader: allowed only if the event is explicitly shared.
!persona_event_is_shared(event)
!event_is_shared(event)
}
/// Returns `true` if the event carries exactly one `["shared", "true"]` tag.
///
/// Kind-agnostic: this is purely the tag-shape predicate. The kind check lives
/// in [`is_shared_gated_kind`], so callers that need "is this event shared"
/// for a kind they already know (e.g. a client deciding whether its own
/// retained head is published) can use this directly.
///
/// 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 {
pub fn event_is_shared(event: &nostr::Event) -> bool {
let mut count = 0usize;
for tag in event.tags.iter() {
let parts = tag.as_slice();
@@ -258,6 +277,34 @@ pub const KIND_TEAM: u32 = 30176;
/// since these events are world-readable on the relay.
pub const KIND_MANAGED_AGENT: u32 = 30177;
/// NIP-AP: Team Catalog projection (parameterized replaceable, owner-authored).
///
/// The shareable projection of a team, addressed by `(pubkey, kind, d_tag)`
/// where `d_tag` is the team's stable id. Content is a versioned JSON body
/// carrying sanitized team fields plus ordered, EMBEDDED member definition
/// projections.
///
/// # Why this is not a `shared` tag on [`KIND_TEAM`]
///
/// A team's members live in kind 30175 events that are author-only unless
/// individually shared, so a foreign reader of a shared team could never
/// hydrate its members. This kind therefore embeds the member projections
/// rather than referencing them: the share is atomic, it covers built-in
/// members that have no 30175 head at all, it is immune to local-id/d-tag
/// divergence, and an unshared 30175 stays private. Kind 30176's wire body is
/// untouched, so device sync keeps its contract.
///
/// # Access control
///
/// Member of [`SHARED_GATED_KINDS`]: author-only unless the event carries
/// exactly `["shared", "true"]`. Ingest additionally requires exactly one
/// non-empty, bounded `d` tag — generic NIP-33 storage maps a missing `d` to
/// the empty coordinate, which would collapse every team into one slot.
///
/// Content carries only sanitized fields: no env vars, no `respond_to`
/// allowlist pubkeys, no source or local ids, no filesystem paths, no secrets.
pub const KIND_TEAM_CATALOG: u32 = 30178;
// NIP-56 reporting
/// NIP-56: Report an event, pubkey, or blob to relay moderators (kind:1984).
///
@@ -586,6 +633,7 @@ pub const ALL_KINDS: &[u32] = &[
KIND_PERSONA,
KIND_TEAM,
KIND_MANAGED_AGENT,
KIND_TEAM_CATALOG,
KIND_REPORT,
KIND_PRODUCT_FEEDBACK,
KIND_NIP29_PUT_USER,
@@ -784,6 +832,7 @@ const _: () = assert!(is_replaceable(KIND_AGENT_PROFILE)); // 10100 ∈ 10000–
const _: () = assert!(is_parameterized_replaceable(KIND_PERSONA)); // 30175 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_TEAM)); // 30176 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_MANAGED_AGENT)); // 30177 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_TEAM_CATALOG)); // 30178 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999
@@ -858,64 +907,68 @@ mod tests {
}
}
// ── persona_event_is_shared / is_unshared_persona_event ──────────────
// ── event_is_shared / is_unshared_gated_event ────────────────────────
fn make_persona_event(tags: &[&[&str]]) -> nostr::Event {
fn make_event_of_kind(kind: u32, 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), "")
EventBuilder::new(Kind::Custom(kind as u16), "")
.tags(tag_vec)
.sign_with_keys(&keys)
.unwrap()
}
fn make_persona_event(tags: &[&[&str]]) -> nostr::Event {
make_event_of_kind(KIND_PERSONA, tags)
}
#[test]
fn persona_event_is_shared_true_tag() {
fn event_is_shared_true_tag() {
let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "true"]]);
assert!(persona_event_is_shared(&ev));
assert!(event_is_shared(&ev));
}
#[test]
fn persona_event_is_shared_no_tag() {
fn event_is_shared_no_tag() {
let ev = make_persona_event(&[&["d", "my-agent"]]);
assert!(!persona_event_is_shared(&ev));
assert!(!event_is_shared(&ev));
}
#[test]
fn persona_event_is_shared_wrong_value() {
fn event_is_shared_wrong_value() {
let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "false"]]);
assert!(!persona_event_is_shared(&ev));
assert!(!event_is_shared(&ev));
}
#[test]
fn persona_event_is_shared_duplicate_shared_tags() {
fn 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));
assert!(!event_is_shared(&ev));
}
#[test]
fn persona_event_is_shared_three_element_tag_not_shared() {
fn 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));
assert!(!event_is_shared(&ev));
}
#[test]
fn persona_event_is_shared_one_element_tag_not_shared() {
fn 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));
assert!(!event_is_shared(&ev));
}
#[test]
fn is_unshared_persona_event_author_always_allowed() {
fn is_unshared_gated_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();
@@ -924,32 +977,83 @@ mod tests {
.sign_with_keys(&keys)
.unwrap();
let author_bytes = keys.public_key().to_bytes();
assert!(!is_unshared_persona_event(&ev, &author_bytes));
assert!(!is_unshared_gated_event(&ev, &author_bytes));
}
#[test]
fn is_unshared_persona_event_foreign_no_tag() {
fn is_unshared_gated_event_foreign_no_tag() {
let ev = make_persona_event(&[&["d", "my-agent"]]);
let foreign = [0u8; 32];
assert!(is_unshared_persona_event(&ev, &foreign));
assert!(is_unshared_gated_event(&ev, &foreign));
}
#[test]
fn is_unshared_persona_event_foreign_shared_tag() {
fn is_unshared_gated_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));
assert!(!is_unshared_gated_event(&ev, &foreign));
}
#[test]
fn is_unshared_persona_event_non_persona_kind_passthrough() {
fn is_unshared_gated_event_ungated_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));
// Kinds outside SHARED_GATED_KINDS are never blocked by this gate.
assert!(!is_unshared_gated_event(&ev, &foreign));
}
#[test]
fn is_unshared_gated_event_team_catalog_foreign_no_tag() {
// The gate must cover 30178 identically to 30175 — an unshared team
// catalog projection is author-only.
let ev = make_event_of_kind(KIND_TEAM_CATALOG, &[&["d", "team-1"]]);
let foreign = [0u8; 32];
assert!(is_unshared_gated_event(&ev, &foreign));
}
#[test]
fn is_unshared_gated_event_team_catalog_foreign_shared_tag() {
let ev = make_event_of_kind(KIND_TEAM_CATALOG, &[&["d", "team-1"], &["shared", "true"]]);
let foreign = [0u8; 32];
assert!(!is_unshared_gated_event(&ev, &foreign));
}
#[test]
fn is_unshared_gated_event_team_catalog_author_always_allowed() {
use nostr::{EventBuilder, Keys, Kind, Tag};
let keys = Keys::generate();
let ev = EventBuilder::new(Kind::Custom(KIND_TEAM_CATALOG as u16), "")
.tags(vec![Tag::parse(["d", "team-1"]).unwrap()])
.sign_with_keys(&keys)
.unwrap();
let author_bytes = keys.public_key().to_bytes();
assert!(!is_unshared_gated_event(&ev, &author_bytes));
}
#[test]
fn is_unshared_gated_event_team_catalog_malformed_shared_tag_fails_closed() {
// A three-element `shared` tag can never be stored (ingest rejects it),
// but the read gate must independently treat it as NOT shared.
let ev = make_event_of_kind(
KIND_TEAM_CATALOG,
&[&["d", "team-1"], &["shared", "true", "extra"]],
);
let foreign = [0u8; 32];
assert!(is_unshared_gated_event(&ev, &foreign));
}
#[test]
fn shared_gated_kinds_membership() {
assert!(is_shared_gated_kind(KIND_PERSONA));
assert!(is_shared_gated_kind(KIND_TEAM_CATALOG));
// 30176 has owner-private semantics, not catalog opt-in semantics: its
// writers never emit `shared`, so gating it here would hide every team
// from its own delegated readers.
assert!(!is_shared_gated_kind(KIND_TEAM));
assert!(!is_shared_gated_kind(KIND_MANAGED_AGENT));
}
}

View File

@@ -11,7 +11,7 @@ use uuid::Uuid;
use buzz_core::kind::{
event_kind_i32, is_ephemeral, is_parameterized_replaceable, KIND_AUTH, KIND_EVENT_REMINDER,
KIND_HUDDLE_STARTED,
KIND_HUDDLE_STARTED, SHARED_GATED_KINDS,
};
use buzz_core::{CommunityId, StoredEvent};
@@ -71,13 +71,15 @@ 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.
/// Shared-gated visibility reader: when set, append an SQL visibility
/// clause for every kind in [`SHARED_GATED_KINDS`] before ORDER/LIMIT so
/// private events 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.
/// The clause is: `AND (kind NOT IN (...) OR pubkey = $reader OR tags @> ?)`,
/// where the `IN` list is [`SHARED_GATED_KINDS`] and `?` 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
@@ -85,7 +87,7 @@ pub struct EventQuery {
/// 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>>,
pub shared_gated_reader: Option<Vec<u8>>,
}
impl EventQuery {
@@ -114,7 +116,7 @@ impl EventQuery {
e_tags: None,
channel_ids: None,
max_limit: None,
persona_reader: None,
shared_gated_reader: None,
}
}
}
@@ -512,25 +514,28 @@ pub(crate) async fn query_events_on(
}
}
// 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).
// Shared-gated visibility pushdown: exclude SHARED_GATED_KINDS events that
// are neither authored by the reader nor explicitly shared. Applied BEFORE
// ORDER/LIMIT so that a page of newer private events 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"]]')
// Clause: AND (kind NOT IN (30175, 30178) 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;
// tag passes; a tag-absent event does not. Because ingest 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.shared_gated_reader {
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(format!(" AND ({col_prefix}kind NOT IN ("));
let mut sep = qb.separated(", ");
for kind in SHARED_GATED_KINDS {
sep.push_bind(*kind as i32);
}
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);

View File

@@ -1236,10 +1236,10 @@ 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());
// Shared-gated visibility pushdown: must mirror WS REQ so that a page of
// newer private events does not starve older shared ones off the page.
if crate::handlers::req::filter_can_match_shared_gated_kinds(filter) {
query.shared_gated_reader = Some(pubkey_bytes.clone());
}
match extract_before_id(raw) {
@@ -1453,11 +1453,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);
// Force per-event fallback for filters that can match a shared-gated
// kind — the fast SQL count_events() path has no per-event gate and
// would over-count foreign unshared events (existence leak).
let needs_shared_gate_filtering =
crate::handlers::req::filter_can_match_shared_gated_kinds(filter);
// If filter targets a specific channel, verify access.
if let Some(ch_id) = extract_channel_from_filter(filter) {
@@ -1472,10 +1472,10 @@ async fn count_events_authed(
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());
// Shared-gated visibility pushdown: same as REQ and /query paths, so
// the fallback's query_events call doesn't over-fetch private rows.
if needs_shared_gate_filtering {
query.shared_gated_reader = Some(pubkey_bytes.clone());
}
let author_is_self = filter.authors.as_ref().is_some_and(|authors| {
!authors.is_empty()
@@ -1486,7 +1486,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
&& !needs_shared_gate_filtering
{
match state.db.count_events_routed("bridge_count", &query).await {
Ok(n) => total += n as u64,
@@ -1541,10 +1541,10 @@ 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());
// Shared-gated visibility pushdown: pre-filter before ORDER/LIMIT on
// the fallback query_events path.
if needs_shared_gate_filtering {
query.shared_gated_reader = Some(pubkey_bytes.clone());
}
let author_is_self = filter.authors.as_ref().is_some_and(|authors| {
@@ -1556,7 +1556,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
&& !needs_shared_gate_filtering
{
query.limit = None;
match state.db.count_events_routed("bridge_count", &query).await {

View File

@@ -7,8 +7,8 @@ use tracing::warn;
use crate::connection::{AuthState, ConnectionState};
use crate::handlers::req::{
event_visible_to_reader, filter_can_match_persona_shared_kinds,
filter_can_match_result_gated_kinds, result_gated_count_safe_for_pushdown,
event_visible_to_reader, filter_can_match_result_gated_kinds,
filter_can_match_shared_gated_kinds, result_gated_count_safe_for_pushdown,
};
use crate::protocol::RelayMessage;
use crate::state::AppState;
@@ -103,11 +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 a shared-gated kind (30175, 30178)
// — if so, the fast path must be bypassed because it has no per-event
// shared-tag check. A fast count over those kinds would include foreign
// unshared events, leaking the existence of private agent activity.
let needs_shared_gate_filtering = filter_can_match_shared_gated_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
@@ -157,10 +157,10 @@ pub async fn handle_count(
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());
// Shared-gated visibility pushdown: pre-filter the fallback
// query_events candidate page before ORDER/LIMIT.
if needs_shared_gate_filtering {
query.shared_gated_reader = Some(pubkey_bytes.clone());
}
let author_is_self = filter.authors.as_ref().is_some_and(|authors| {
!authors.is_empty()
@@ -171,7 +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
&& !needs_shared_gate_filtering
{
match state.db.count_events_routed("count_req", &query).await {
Ok(n) => total += n as u64,
@@ -230,9 +230,9 @@ 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());
// Shared-gated visibility pushdown for the fallback query_events path.
if needs_shared_gate_filtering {
query.shared_gated_reader = Some(pubkey_bytes.clone());
}
let author_is_self = filter.authors.as_ref().is_some_and(|authors| {
@@ -244,7 +244,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
&& !needs_shared_gate_filtering
{
query.limit = None; // COUNT doesn't need a row limit
match state.db.count_events_routed("count_req", &query).await {

View File

@@ -7,7 +7,7 @@ use tracing::{debug, error, info, warn};
use buzz_core::event::StoredEvent;
use buzz_core::kind::{
event_kind_u32, is_ephemeral, is_unshared_persona_event, AUTHOR_ONLY_KINDS,
event_kind_u32, is_ephemeral, is_unshared_gated_event, AUTHOR_ONLY_KINDS,
KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP, KIND_PRESENCE_UPDATE,
};
use buzz_core::observer::{
@@ -151,10 +151,10 @@ 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)) {
// Shared-read gate (fan-out): SHARED_GATED_KINDS events fan out to all
// connections only when carrying ["shared","true"]. Unshared ones are
// delivered only to the author's own connections, matching REQ semantics.
let matches = if buzz_core::kind::is_shared_gated_kind(event_kind_u32(&stored_event.event)) {
let author = stored_event.event.pubkey.to_bytes();
matches
.into_iter()
@@ -167,7 +167,7 @@ pub async fn filter_fanout_by_access(
return true;
}
// Foreign connection: allowed only if the event is shared.
!is_unshared_persona_event(&stored_event.event, &pk)
!is_unshared_gated_event(&stored_event.event, &pk)
})
.collect()
} else {

View File

@@ -31,9 +31,9 @@ use buzz_core::kind::{
KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_REPORT,
KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF,
KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED,
KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEXT_NOTE, KIND_USER_STATUS,
KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE,
RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE,
KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE,
KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER,
RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE,
};
use buzz_core::tenant::TenantContext;
use buzz_core::verification::verify_event;
@@ -214,7 +214,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite),
KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM
| KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT
| super::push_lease::KIND_PUSH_LEASE => {
| KIND_TEAM_CATALOG | super::push_lease::KIND_PUSH_LEASE => {
Ok(Scope::UsersWrite)
}
// NIP-AM: agent turn metrics are agent-authored global events (encrypted to owner).
@@ -419,10 +419,12 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
| KIND_AGENT_PROFILE
// NIP-AP: persona definitions (30175): owner-authored, keyed by (pubkey, kind, d_tag).
| KIND_PERSONA
// NIP-AP: team (30176) + managed-agent (30177) definitions: owner-authored,
// keyed by (pubkey, kind, d_tag). A stray `h` tag must not channel-scope them.
// NIP-AP: team (30176) + managed-agent (30177) definitions and the
// team-catalog projection (30178): owner-authored, keyed by
// (pubkey, kind, d_tag). A stray `h` tag must not channel-scope them.
| KIND_TEAM
| KIND_MANAGED_AGENT
| KIND_TEAM_CATALOG
// NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope).
// Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag).
| KIND_GIT_REPO_ANNOUNCEMENT
@@ -1029,37 +1031,27 @@ fn validate_engram_envelope(event: &Event) -> Result<(), String> {
Ok(())
}
/// Validate the envelope of a kind:30175 persona event.
/// Enforce the `shared`-tag shape shared by every kind in
/// [`buzz_core::kind::SHARED_GATED_KINDS`]: at most one `shared` tag, and if
/// present it must be exactly `["shared", "true"]`.
///
/// 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"`.
/// This 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 elements, duplicate tags) is rejected
/// at ingest so read-path helpers — including the SQL-level `tags @>
/// '[["shared","true"]]'` containment clause, which would otherwise match a
/// three-element superset — can treat stored events as unambiguously one or the
/// other.
///
/// 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();
/// `label` names the kind in error messages (e.g. `"persona event"`).
fn validate_shared_tag(event: &Event, label: &str) -> Result<(), String> {
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 {:?})",
"{label} `shared` tag must be exactly [\"shared\",\"true\"] (got {:?})",
parts.iter().map(|s| s.as_str()).collect::<Vec<_>>()
));
}
@@ -1068,43 +1060,106 @@ fn validate_persona_envelope(event: &Event) -> Result<(), String> {
}
if shared_count > 1 {
return Err(format!(
"persona event must have at most one `shared` tag (got {shared_count})"
"{label} must have at most one `shared` tag (got {shared_count})"
));
}
Ok(())
}
/// Return the event's single `d` tag value, requiring exactly one tag whose
/// value is non-empty, at most 64 characters, and free of Unicode control
/// characters and whitespace.
///
/// Without this check an empty `d` tag collapses every event of the kind into
/// the `(pubkey, kind, "")` slot — last-write-wins data loss. The character
/// bound keeps the value usable as a NIP-33 coordinate (`<kind>:<pubkey>:<d>`)
/// and as a log field: an embedded newline or tab would break line-oriented
/// consumers of both.
///
/// Tags are counted by their first element alone, so a valueless `["d"]`
/// counts. Skipping it would let `["d"]` plus `["d", "team-1"]` pass the
/// exactly-one rule, and a NIP-33 consumer that reads `["d"]` as an
/// empty-valued first `d` tag would then address the event at `""` where this
/// relay addresses it at `"team-1"`.
///
/// `label` names the kind in error messages (e.g. `"persona event"`).
fn single_bounded_d_tag<'a>(event: &'a Event, label: &str) -> Result<&'a str, String> {
let d_tags: Vec<Option<&str>> = event
.tags
.iter()
.filter_map(|tag| {
let parts = tag.as_slice();
(parts.first().map(|name| name.as_str()) == Some("d"))
.then(|| parts.get(1).map(|value| value.as_str()))
})
.collect();
if d_tags.len() != 1 {
return Err(format!(
"persona event must have exactly one `d` tag (got {})",
"{label} must have exactly one `d` tag (got {})",
d_tags.len()
));
}
let d = d_tags[0];
let d = d_tags[0].unwrap_or_default();
if d.is_empty() {
return Err("persona event `d` tag must not be empty".to_string());
return Err(format!("{label} `d` tag must not be empty"));
}
// Slug grammar: ^[a-z0-9][a-z0-9_-]{0,63}$
if d.len() > 64 {
let char_count = d.chars().count();
if char_count > 64 {
return Err(format!(
"persona event `d` tag too long ({} chars, max 64)",
d.len()
"{label} `d` tag too long ({char_count} chars, max 64)"
));
}
if d.chars().any(|c| c.is_control() || c.is_whitespace()) {
return Err(format!(
"{label} `d` tag must not contain control characters or whitespace"
));
}
Ok(d)
}
/// Validate the envelope of a kind:30175 persona event.
///
/// Enforces the shared-gated `shared`-tag shape ([`validate_shared_tag`]) plus
/// exactly one `d` tag matching the persona slug grammar
/// `^[a-z0-9][a-z0-9_-]{0,63}$`.
fn validate_persona_envelope(event: &Event) -> Result<(), String> {
const LABEL: &str = "persona event";
validate_shared_tag(event, LABEL)?;
let d = single_bounded_d_tag(event, LABEL)?;
// Slug grammar: ^[a-z0-9][a-z0-9_-]{0,63}$
let bytes = d.as_bytes();
if !bytes[0].is_ascii_lowercase() && !bytes[0].is_ascii_digit() {
return Err(
"persona event `d` tag must start with a lowercase letter or digit".to_string(),
);
return Err(format!(
"{LABEL} `d` tag must start with a lowercase letter or digit"
));
}
if !bytes[1..]
.iter()
.all(|&b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
{
return Err(
"persona event `d` tag must match [a-z0-9_-] after the first character".to_string(),
);
return Err(format!(
"{LABEL} `d` tag must match [a-z0-9_-] after the first character"
));
}
Ok(())
}
/// Validate the envelope of a kind:30178 team-catalog event.
///
/// Enforces the shared-gated `shared`-tag shape ([`validate_shared_tag`]) plus
/// exactly one non-empty, bounded `d` tag.
///
/// Deliberately NOT the persona slug grammar: a team's `d` tag is its stable
/// local id, which is either a UUID or a built-in identifier such as
/// `builtin-team:welcome` — the colon is not slug-legal, and rewriting ids to
/// fit would break NIP-33 addressing against the team's own kind:30176 head.
fn validate_team_catalog_envelope(event: &Event) -> Result<(), String> {
const LABEL: &str = "team-catalog event";
validate_shared_tag(event, LABEL)?;
single_bounded_d_tag(event, LABEL)?;
Ok(())
}
/// Validate that `content` is a syntactically plausible NIP-44 v2 ciphertext.
///
/// Checks:
@@ -2070,6 +2125,11 @@ async fn ingest_event_inner(
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
}
if kind_u32 == KIND_TEAM_CATALOG {
validate_team_catalog_envelope(&event)
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
}
// Track pre-created channel UUID for compensation on insert failure.
let mut pre_created_channel: Option<Uuid> = None;
@@ -3597,6 +3657,24 @@ mod tests {
assert!(err.contains("`d` tag"), "got: {err}");
}
#[test]
fn persona_envelope_rejects_valueless_d_tag() {
// A lone ["d"] carries no value; it must fail as a missing value, not
// be skipped as though the event had no `d` tag at all.
let ev = make_persona(&[&["d"]]);
let err = validate_persona_envelope(&ev).unwrap_err();
assert!(err.contains("must not be empty"), "got: {err}");
}
#[test]
fn persona_envelope_rejects_valueless_plus_valued_d_tags() {
// Counting only tags with a value would see one `d` here and accept the
// event, breaking the exactly-one rule.
let ev = make_persona(&[&["d"], &["d", "slug-a"]]);
let err = validate_persona_envelope(&ev).unwrap_err();
assert!(err.contains("exactly one `d` tag"), "got: {err}");
}
#[test]
fn persona_envelope_rejects_too_long() {
let slug = "a".repeat(65);
@@ -3723,6 +3801,151 @@ mod tests {
);
}
// ─── team-catalog (30178) envelope tests ─────────────────────────────────
fn make_team_catalog(tags: &[&[&str]]) -> Event {
make_event_with_tags(
KIND_TEAM_CATALOG,
r#"{"v":1,"name":"Team","members":[]}"#,
tags,
)
}
#[test]
fn team_catalog_envelope_accepts_uuid_d_tag() {
let ev = make_team_catalog(&[&["d", "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0"]]);
assert!(validate_team_catalog_envelope(&ev).is_ok());
}
#[test]
fn team_catalog_envelope_accepts_builtin_colon_d_tag() {
// Built-in team ids carry a colon (`builtin-team:welcome`), which the
// persona slug grammar forbids. The catalog `d` tag must accept them so
// a built-in team can be shared under its real local id.
let ev = make_team_catalog(&[&["d", "builtin-team:welcome"]]);
assert!(validate_team_catalog_envelope(&ev).is_ok());
}
#[test]
fn team_catalog_envelope_accepts_shared_true() {
let ev = make_team_catalog(&[&["d", "team-1"], &["shared", "true"]]);
assert!(validate_team_catalog_envelope(&ev).is_ok());
}
#[test]
fn team_catalog_envelope_rejects_missing_d_tag() {
let ev = make_team_catalog(&[]);
let err = validate_team_catalog_envelope(&ev).unwrap_err();
assert!(err.contains("exactly one `d` tag"), "got: {err}");
}
#[test]
fn team_catalog_envelope_rejects_empty_d_tag() {
// An empty d-tag collapses every team into the (pubkey, 30178, "") slot.
let ev = make_team_catalog(&[&["d", ""]]);
let err = validate_team_catalog_envelope(&ev).unwrap_err();
assert!(err.contains("must not be empty"), "got: {err}");
}
#[test]
fn team_catalog_envelope_rejects_duplicate_d_tags() {
let ev = make_team_catalog(&[&["d", "team-1"], &["d", "team-2"]]);
let err = validate_team_catalog_envelope(&ev).unwrap_err();
assert!(err.contains("exactly one `d` tag"), "got: {err}");
}
#[test]
fn team_catalog_envelope_rejects_valueless_d_tag() {
// A lone ["d"] carries no value; it must fail as a missing value, not
// be skipped as though the event had no `d` tag at all.
let ev = make_team_catalog(&[&["d"]]);
let err = validate_team_catalog_envelope(&ev).unwrap_err();
assert!(err.contains("must not be empty"), "got: {err}");
}
#[test]
fn team_catalog_envelope_rejects_valueless_plus_valued_d_tags() {
// Counting only tags with a value would see one `d` here and accept the
// event. A NIP-33 consumer that reads ["d"] as an empty-valued first
// `d` tag would then address this event at "" where we address it at
// "team-1".
let ev = make_team_catalog(&[&["d"], &["d", "team-1"]]);
let err = validate_team_catalog_envelope(&ev).unwrap_err();
assert!(err.contains("exactly one `d` tag"), "got: {err}");
}
#[test]
fn team_catalog_envelope_bounds_d_tag_by_chars_not_bytes() {
// 64 multi-byte characters is 192 bytes; the documented bound is
// characters, so this must be accepted.
let d = "é".repeat(64);
assert!(d.len() > 64, "fixture must exceed the bound in bytes");
let ev = make_team_catalog(&[&["d", &d]]);
assert!(validate_team_catalog_envelope(&ev).is_ok());
}
#[test]
fn team_catalog_envelope_rejects_too_long_d_tag() {
let d = "a".repeat(65);
let ev = make_team_catalog(&[&["d", &d]]);
let err = validate_team_catalog_envelope(&ev).unwrap_err();
assert!(err.contains("too long"), "got: {err}");
}
#[test]
fn team_catalog_envelope_accepts_max_length_d_tag() {
let d = "a".repeat(64);
let ev = make_team_catalog(&[&["d", &d]]);
assert!(validate_team_catalog_envelope(&ev).is_ok());
}
#[test]
fn team_catalog_envelope_rejects_whitespace_d_tag() {
// A newline in the d-tag would break the NIP-33 coordinate and any
// line-oriented log consumer.
let ev = make_team_catalog(&[&["d", "team\n1"]]);
let err = validate_team_catalog_envelope(&ev).unwrap_err();
assert!(err.contains("control characters"), "got: {err}");
}
#[test]
fn team_catalog_envelope_rejects_shared_false() {
let ev = make_team_catalog(&[&["d", "team-1"], &["shared", "false"]]);
let err = validate_team_catalog_envelope(&ev).unwrap_err();
assert!(err.contains("\"true\""), "got: {err}");
}
#[test]
fn team_catalog_envelope_rejects_shared_three_elements() {
// Same exact-shape rule as personas: a three-element tag would match the
// SQL containment clause `tags @> '[["shared","true"]]'` as a superset.
let ev = make_team_catalog(&[&["d", "team-1"], &["shared", "true", "extra"]]);
let err = validate_team_catalog_envelope(&ev).unwrap_err();
assert!(err.contains("[\"shared\",\"true\"]"), "got: {err}");
}
#[test]
fn team_catalog_envelope_rejects_duplicate_shared_tags() {
let ev = make_team_catalog(&[&["d", "team-1"], &["shared", "true"], &["shared", "true"]]);
let err = validate_team_catalog_envelope(&ev).unwrap_err();
assert!(err.contains("at most one"), "got: {err}");
}
#[test]
fn team_catalog_is_in_scope_allowlist() {
let dummy = make_dummy_event();
assert_eq!(
required_scope_for_kind(KIND_TEAM_CATALOG, &dummy).unwrap(),
Scope::UsersWrite,
);
}
#[test]
fn team_catalog_is_global_only() {
assert!(is_global_only_kind(KIND_TEAM_CATALOG));
assert!(!requires_h_channel_scope(KIND_TEAM_CATALOG));
}
// ─── agent_turn_metric envelope tests ────────────────────────────────────
/// Build an event for kind:44200 with the given tags and content.

View File

@@ -7,8 +7,8 @@ use tracing::{debug, warn};
use buzz_core::filter::filters_match;
use buzz_core::kind::{
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,
is_unshared_gated_event, AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC,
KIND_DM_VISIBILITY, P_GATED_KINDS, RESULT_GATED_KINDS, SHARED_GATED_KINDS,
};
use buzz_core::tenant::TenantContext;
use buzz_db::EventQuery;
@@ -290,11 +290,11 @@ 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());
// Shared-gated visibility pushdown: set reader bytes so query_events
// appends the SQL visibility clause before ORDER/LIMIT, preventing
// newer private events from starving older shared ones off the page.
if filter_can_match_shared_gated_kinds(filter) {
params.shared_gated_reader = Some(pubkey_bytes.clone());
}
(idx, per_filter_channel, params)
})
@@ -1137,19 +1137,20 @@ 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.
/// Returns `true` if the filter CAN match any kind in [`SHARED_GATED_KINDS`] —
/// meaning it either has no `kinds` constraint (wildcard) or explicitly includes
/// one of them.
///
/// 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
/// `is_unshared_gated_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))
/// events — leaking the existence of private persona/team-catalog activity even
/// without returning content.
pub(crate) fn filter_can_match_shared_gated_kinds(filter: &Filter) -> bool {
filter.kinds.as_ref().is_none_or(|ks| {
ks.iter()
.any(|k| SHARED_GATED_KINDS.contains(&(k.as_u16() as u32)))
})
}
/// Returns `true` if the filter CAN match result-gated kinds — meaning it
@@ -1208,8 +1209,9 @@ pub(crate) fn is_author_only_event(event: &nostr::Event, requester_pubkey_bytes:
///
/// 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.
/// 2. **Shared-gate** (`SHARED_GATED_KINDS`, e.g. kind 30175/30178 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.
///
@@ -1223,7 +1225,7 @@ pub(crate) fn event_visible_to_reader(event: &nostr::Event, requester_pubkey_byt
if is_author_only_event(event, requester_pubkey_bytes) {
return false;
}
if is_unshared_persona_event(event, requester_pubkey_bytes) {
if is_unshared_gated_event(event, requester_pubkey_bytes) {
return false;
}
let requester_pubkey_hex = hex::encode(requester_pubkey_bytes);

View File

@@ -1324,7 +1324,7 @@ async fn test_persona_http_query_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.
/// path (prevented by `needs_shared_gate_filtering`) and the fallback path.
#[tokio::test]
#[ignore]
async fn test_persona_http_count_cross_author_gate() {
@@ -1403,7 +1403,7 @@ async fn test_persona_http_count_cross_author_gate() {
/// 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
/// not have the `shared_gated_reader` SQL clause and the private rows starved the
/// shared one off the page.
#[tokio::test]
#[ignore]

View File

@@ -0,0 +1,484 @@
//! End-to-end tests for kind:30178 team-catalog events (NIP-AP).
//!
//! Kind 30178 is the shareable projection of a team. It joins kind:30175 in
//! `SHARED_GATED_KINDS`, so these tests assert the wire behaviour of that gate
//! at every read chokepoint (REQ, `ids` lookup, COUNT, live fan-out) plus the
//! ingest envelope rules that make the gate sound:
//! - Exactly one non-empty, bounded `d` tag — the team's stable local id, which
//! may contain a colon (`builtin-team:welcome`) unlike a persona slug.
//! - `shared`, if present, is exactly `["shared", "true"]`.
//!
//! # Running
//!
//! Start the relay, then run:
//!
//! ```text
//! RELAY_URL=ws://localhost:3000 cargo test --test e2e_team_catalog -- --ignored
//! ```
use std::time::Duration;
use buzz_test_client::{BuzzTestClient, RelayMessage};
use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp};
const TEAM_CATALOG_KIND: u16 = 30178;
fn relay_url() -> String {
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
}
fn sub_id(name: &str) -> String {
format!("e2e-team-catalog-{name}-{}", uuid::Uuid::new_v4())
}
fn catalog_content(name: &str) -> String {
serde_json::json!({ "v": 1, "name": name, "members": [] }).to_string()
}
/// Build a kind:30178 event, optionally carrying the `["shared","true"]` opt-in.
fn catalog_event(keys: &Keys, d_tag: &str, shared: bool) -> nostr::Event {
catalog_event_at(keys, d_tag, shared, Timestamp::now().as_secs())
}
/// Same as [`catalog_event`] with an explicit `created_at`, so NIP-33 head
/// ordering is deterministic instead of resolved by event-id tie-break.
fn catalog_event_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(TEAM_CATALOG_KIND),
catalog_content("Test Team"),
)
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.sign_with_keys(keys)
.unwrap()
}
fn author_filter(author: &Keys) -> Filter {
Filter::new()
.kind(Kind::Custom(TEAM_CATALOG_KIND))
.author(author.public_key())
}
fn coordinate_filter(author: &Keys, d_tag: &str) -> Filter {
author_filter(author).custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag])
}
fn d_tag_of(event: &nostr::Event) -> Option<&str> {
event.tags.iter().find_map(|t| {
let parts = t.as_slice();
if parts.first().map(|p| p.as_str()) != Some("d") {
return None;
}
Some(parts.get(1)?.as_str())
})
}
/// The author's own unshared projection round-trips at its NIP-33 coordinate.
///
/// The `d` tag is a UUID, matching the desktop team id — proof the envelope does
/// NOT apply the persona slug grammar.
#[tokio::test]
#[ignore]
async fn test_team_catalog_publish_and_query_own_unshared() {
let url = relay_url();
let keys = Keys::generate();
let d_tag = uuid::Uuid::new_v4().to_string();
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let event = catalog_event(&keys, &d_tag, false);
let event_id = event.id;
let ok = client.send_event(event).await.expect("send catalog");
assert!(ok.accepted, "relay rejected catalog event: {}", ok.message);
let sid = sub_id("own-unshared");
client
.subscribe(&sid, vec![coordinate_filter(&keys, &d_tag)])
.await
.expect("subscribe");
let events = client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect");
assert_eq!(events.len(), 1, "author must see own unshared projection");
assert_eq!(events[0].id, event_id);
client.disconnect().await.expect("disconnect");
}
/// A built-in team id (`builtin-team:welcome`) is accepted as the `d` tag.
///
/// The colon is illegal in a persona slug; rewriting the id to fit would break
/// NIP-33 addressing against the team's own kind:30176 head.
#[tokio::test]
#[ignore]
async fn test_team_catalog_accepts_builtin_colon_d_tag() {
let url = relay_url();
let keys = Keys::generate();
let d_tag = format!("builtin-team:{}", &uuid::Uuid::new_v4().to_string()[..8]);
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let ok = client
.send_event(catalog_event(&keys, &d_tag, true))
.await
.expect("send catalog");
assert!(
ok.accepted,
"relay rejected colon-bearing team id: {}",
ok.message
);
client.disconnect().await.expect("disconnect");
}
/// Ingest refuses an empty `d` tag: generic NIP-33 storage maps it to the empty
/// coordinate, collapsing every team into one `(pubkey, 30178, "")` slot.
#[tokio::test]
#[ignore]
async fn test_team_catalog_rejects_empty_d_tag() {
let url = relay_url();
let keys = Keys::generate();
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let ok = client
.send_event(catalog_event(&keys, "", false))
.await
.expect("send catalog");
assert!(!ok.accepted, "empty d-tag must be rejected");
assert!(
ok.message.contains("invalid:"),
"expected an `invalid:` refusal, got: {}",
ok.message
);
client.disconnect().await.expect("disconnect");
}
/// Ingest refuses a valueless `["d"]` tag alongside a valued one. Counting only
/// tags that carry a value would see exactly one `d` here and accept the event;
/// a NIP-33 consumer that reads `["d"]` as an empty-valued first `d` tag would
/// then address the event at `""` where this relay addresses it at the team id.
#[tokio::test]
#[ignore]
async fn test_team_catalog_rejects_valueless_plus_valued_d_tags() {
let url = relay_url();
let keys = Keys::generate();
let d_tag = uuid::Uuid::new_v4().to_string();
let event = EventBuilder::new(
Kind::Custom(TEAM_CATALOG_KIND),
catalog_content("Two d tags"),
)
.tags(vec![
Tag::parse(["d"]).unwrap(),
Tag::parse(["d", d_tag.as_str()]).unwrap(),
])
.sign_with_keys(&keys)
.unwrap();
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let ok = client.send_event(event).await.expect("send catalog");
assert!(
!ok.accepted,
"a valueless `d` tag must count toward the exactly-one rule"
);
assert!(
ok.message.contains("invalid:"),
"expected an `invalid:` refusal, got: {}",
ok.message
);
client.disconnect().await.expect("disconnect");
}
/// Ingest refuses a malformed `shared` tag. A three-element tag would satisfy
/// the SQL containment clause `tags @> '[["shared","true"]]'` as a superset
/// while the in-process gate reads it as unshared — the two layers must agree,
/// so such an event can never be stored.
#[tokio::test]
#[ignore]
async fn test_team_catalog_rejects_three_element_shared_tag() {
let url = relay_url();
let keys = Keys::generate();
let d_tag = uuid::Uuid::new_v4().to_string();
let event = EventBuilder::new(
Kind::Custom(TEAM_CATALOG_KIND),
catalog_content("Malformed"),
)
.tags(vec![
Tag::parse(["d", d_tag.as_str()]).unwrap(),
Tag::parse(["shared", "true", "extra"]).unwrap(),
])
.sign_with_keys(&keys)
.unwrap();
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let ok = client.send_event(event).await.expect("send catalog");
assert!(!ok.accepted, "three-element shared tag must be rejected");
assert!(
ok.message.contains("invalid:"),
"expected an `invalid:` refusal, got: {}",
ok.message
);
client.disconnect().await.expect("disconnect");
}
/// REQ historical delivery: a foreign reader receives only shared projections,
/// while the author receives both of their own.
#[tokio::test]
#[ignore]
async fn test_team_catalog_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());
let d_shared = format!("pub-{}", uuid::Uuid::new_v4());
let mut author = BuzzTestClient::connect(&url, &author_keys)
.await
.expect("connect author");
let shared_event = catalog_event(&author_keys, &d_shared, true);
let shared_id = shared_event.id;
let ok = author
.send_event(catalog_event(&author_keys, &d_unshared, false))
.await
.expect("send unshared");
assert!(ok.accepted, "unshared ingest rejected: {}", ok.message);
let ok = author.send_event(shared_event).await.expect("send shared");
assert!(ok.accepted, "shared ingest rejected: {}", ok.message);
let mut foreign = BuzzTestClient::connect(&url, &foreign_keys)
.await
.expect("connect foreign");
let sid = sub_id("fg-all");
foreign
.subscribe(&sid, vec![author_filter(&author_keys)])
.await
.expect("subscribe");
let events = foreign
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect");
assert!(
!events
.iter()
.any(|e| d_tag_of(e) == Some(d_unshared.as_str())),
"foreign reader must NOT see the unshared projection"
);
assert!(
events.iter().any(|e| e.id == shared_id),
"foreign reader must see the shared projection"
);
let sid_author = sub_id("auth-all");
author
.subscribe(&sid_author, vec![author_filter(&author_keys)])
.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 must see both own projections, got {}",
author_events.len()
);
author.disconnect().await.expect("disconnect author");
foreign.disconnect().await.expect("disconnect foreign");
}
/// Knowing an event id does NOT grant access: `{ids:[unshared]}` returns nothing
/// to a foreign reader.
#[tokio::test]
#[ignore]
async fn test_team_catalog_ids_lookup_unshared_returns_nothing_to_foreign() {
let url = relay_url();
let author_keys = Keys::generate();
let foreign_keys = Keys::generate();
let event = catalog_event(&author_keys, &uuid::Uuid::new_v4().to_string(), false);
let event_id = event.id;
let mut author = BuzzTestClient::connect(&url, &author_keys)
.await
.expect("connect author");
let ok = author.send_event(event).await.expect("send");
assert!(ok.accepted, "ingest rejected: {}", ok.message);
author.disconnect().await.expect("disconnect author");
let mut foreign = BuzzTestClient::connect(&url, &foreign_keys)
.await
.expect("connect foreign");
let sid = sub_id("ids-unshared");
foreign
.subscribe(&sid, vec![Filter::new().id(event_id)])
.await
.expect("subscribe");
let events = foreign
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect");
assert!(
events.is_empty(),
"ids-lookup of an unshared projection must return nothing, got {:?}",
events.iter().map(|e| e.id).collect::<Vec<_>>()
);
foreign.disconnect().await.expect("disconnect foreign");
}
/// COUNT must take the per-event fallback for kind:30178 so the aggregate does
/// not leak the existence of unshared projections.
#[tokio::test]
#[ignore]
async fn test_team_catalog_count_excludes_foreign_unshared() {
let url = relay_url();
let author_keys = Keys::generate();
let foreign_keys = Keys::generate();
let mut author = BuzzTestClient::connect(&url, &author_keys)
.await
.expect("connect author");
let ok = author
.send_event(catalog_event(
&author_keys,
&uuid::Uuid::new_v4().to_string(),
false,
))
.await
.expect("send unshared");
assert!(ok.accepted, "unshared rejected: {}", ok.message);
let ok = author
.send_event(catalog_event(
&author_keys,
&uuid::Uuid::new_v4().to_string(),
true,
))
.await
.expect("send shared");
assert!(ok.accepted, "shared rejected: {}", ok.message);
author.disconnect().await.expect("disconnect author");
let mut foreign = BuzzTestClient::connect(&url, &foreign_keys)
.await
.expect("connect foreign");
let sid = sub_id("count");
let count_msg = serde_json::json!(["COUNT", sid, author_filter(&author_keys)]);
foreign.send_raw(&count_msg).await.expect("send COUNT");
let count = match foreign.recv_event(Duration::from_secs(5)).await {
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 must see only the shared projection, got {count}"
);
foreign.disconnect().await.expect("disconnect foreign");
}
/// Live fan-out honours the gate, and unsharing (a NIP-33 replacement that drops
/// the `shared` tag) retracts the projection from foreign readers.
#[tokio::test]
#[ignore]
async fn test_team_catalog_live_fanout_and_unshare_retracts() {
let url = relay_url();
let author_keys = Keys::generate();
let foreign_keys = Keys::generate();
let d_tag = uuid::Uuid::new_v4().to_string();
let now = Timestamp::now().as_secs();
let (t0, t1, t2) = (now.saturating_sub(2), now.saturating_sub(1), now);
// Subscribe BEFORE publishing, scoped to this author so parallel tests
// publishing their own 30178s cannot trip the leak assertion.
let mut foreign = BuzzTestClient::connect(&url, &foreign_keys)
.await
.expect("connect foreign");
let sid = sub_id("fanout");
foreign
.subscribe(&sid, vec![author_filter(&author_keys)])
.await
.expect("subscribe");
let _ = foreign
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("drain eose");
let mut author = BuzzTestClient::connect(&url, &author_keys)
.await
.expect("connect author");
// Unshared publish must NOT reach the foreign connection.
let ok = author
.send_event(catalog_event_at(&author_keys, &d_tag, false, t0))
.await
.expect("send unshared");
assert!(ok.accepted, "unshared rejected: {}", ok.message);
match foreign.recv_event(Duration::from_millis(750)).await {
Err(buzz_test_client::TestClientError::Timeout) => {}
Ok(RelayMessage::Event { event, .. }) if event.kind == Kind::Custom(TEAM_CATALOG_KIND) => {
panic!("unshared projection leaked to foreign live subscription");
}
Ok(_) => {}
Err(e) => panic!("unexpected error awaiting fan-out: {e}"),
}
// Shared replacement MUST reach it.
let shared_event = catalog_event_at(&author_keys, &d_tag, true, t1);
let shared_id = shared_event.id;
let ok = author.send_event(shared_event).await.expect("send shared");
assert!(ok.accepted, "shared rejected: {}", ok.message);
let delivered = loop {
match foreign.recv_event(Duration::from_secs(5)).await {
Ok(RelayMessage::Event { event, .. }) if event.id == shared_id => break true,
Ok(_) => continue,
Err(buzz_test_client::TestClientError::Timeout) => break false,
Err(e) => panic!("unexpected error awaiting shared fan-out: {e}"),
}
};
assert!(
delivered,
"shared projection must fan out to foreign readers"
);
// Unshare: replace at the same coordinate without the tag. Subsequent
// foreign REQs must return nothing.
let ok = author
.send_event(catalog_event_at(&author_keys, &d_tag, false, t2))
.await
.expect("send unshare");
assert!(ok.accepted, "unshare rejected: {}", ok.message);
let sid_post = sub_id("post-unshare");
foreign
.subscribe(&sid_post, vec![coordinate_filter(&author_keys, &d_tag)])
.await
.expect("subscribe post");
let after = foreign
.collect_until_eose(&sid_post, Duration::from_secs(5))
.await
.expect("collect post");
assert!(
after.is_empty(),
"unsharing must retract the projection from foreign readers, got {} event(s)",
after.len()
);
author.disconnect().await.expect("disconnect author");
foreign.disconnect().await.expect("disconnect foreign");
}

View File

@@ -75,11 +75,11 @@ pub(super) fn prepare_persona_publication(
}
fn retained_persona_is_shared(row: Option<&RetainedEvent>) -> bool {
use buzz_core_pkg::kind::persona_event_is_shared;
use buzz_core_pkg::kind::event_is_shared;
use nostr::JsonUtil;
row.and_then(|retained| nostr::Event::from_json(&retained.raw_event).ok())
.is_some_and(|event| persona_event_is_shared(&event))
.is_some_and(|event| event_is_shared(&event))
}
/// Project each persona's catalog visibility from the active relay+owner

View File

@@ -165,7 +165,7 @@ fn migrate_personas_in_dir_at(
scoped_record.shared = existing
.as_ref()
.and_then(|row| nostr::Event::from_json(&row.raw_event).ok())
.is_some_and(|event| buzz_core_pkg::kind::persona_event_is_shared(&event));
.is_some_and(|event| buzz_core_pkg::kind::event_is_shared(&event));
let event = build_persona_event(&scoped_record)
.map_err(|e| format!("failed to build event for '{}': {e}", record.display_name))?
.custom_created_at(monotonic_created_at(

View File

@@ -5,7 +5,7 @@
use std::collections::BTreeMap;
use buzz_core_pkg::kind::{persona_event_is_shared, KIND_PERSONA};
use buzz_core_pkg::kind::{event_is_shared, KIND_PERSONA};
use nostr::{EventBuilder, Kind, Tag};
use serde::{Deserialize, Serialize};
@@ -192,7 +192,7 @@ pub fn persona_from_event(event: &nostr::Event) -> Result<AgentDefinition, Strin
name_pool: content.name_pool,
is_builtin: false,
is_active: true,
shared: persona_event_is_shared(event),
shared: event_is_shared(event),
source_team: None,
source_team_persona_slug: Some(d_tag),
catalog_source: None,

View File

@@ -10,7 +10,7 @@ This NIP defines `kind:30175` persona events — public, addressable definitions
## Kind
This NIP claims `kind:30175` for agent persona definitions. It is in the NIP-33 parameterized replaceable range (30000–39999) per [NIP-01](01.md): addressed by `(pubkey, kind, d_tag)`, with only the latest event per address retained.
This NIP claims `kind:30175` for agent persona definitions and `kind:30178` for the shareable team-catalog projection (see "Team catalog projection: kind:30178"). Both are in the NIP-33 parameterized replaceable range (30000–39999) per [NIP-01](01.md): addressed by `(pubkey, kind, d_tag)`, with only the latest event per address retained.
A dedicated kind (rather than encoding personas as NIP-78 `kind:30078` "Application-specific Data") is taken for the same reasons as [NIP-AE](NIP-AE.md): (1) it isolates this NIP's address space from any other application using the same pubkey — persona slugs cannot collide with another app's `d` tag choices; (2) it lets observers, indexers, and unknown-kind viewers identify persona events from the kind alone, without parsing content as a namespace demultiplexer.
@@ -162,6 +162,8 @@ Owners MAY publish [NIP-09](09.md) deletion requests targeting persona events. A
A subsequent write with a later timestamp resurrects the slug under NIP-33 replacement semantics.
The same applies to `kind:30178`: a deletion request SHOULD carry `["k", "30178"]` and the `a`-tag identifier `30178:<pubkey_o>:<team-id>`. Unsharing is distinct from deletion — it is a newer valid head at the same coordinate published *without* the `shared` tag, which keeps the projection readable to its author while retracting it from foreign readers.
## Relationships to other NIPs
### NIP-AE (Agent Engrams)
@@ -216,6 +218,29 @@ surface per-event errors.
Agents spawned from a persona carry [NIP-OA](NIP-OA.md) owner attestation — an `auth` tag proving that `pubkey_o` authorized the agent's key. The persona event itself does not contain attestation; it is the *definition* from which attestation is issued at spawn time.
## Team catalog projection: kind:30178
Kind `30178` is the **shareable projection of a team**: owner-authored, parameterized replaceable, addressed by `(pubkey_o, 30178, d)` where `d` is the team's stable local id. Its `content` is a versioned JSON body carrying sanitized team fields plus ordered, *embedded* member definition projections. The content schema is defined by the client that publishes it; this section specifies only the envelope and the relay's contract.
```jsonc
{
"kind": 30178,
"pubkey": "<pubkey_o>",
"created_at": <unix_seconds>,
"tags": [
["d", "<team-id>"],
["shared", "true"] // optional; presence opts the projection into community reads
],
"content": "<json_body>"
}
```
**Why a separate kind rather than a `shared` tag on the team event (kind:30176).** A team's members are `kind:30175` definitions, which are author-only unless individually shared — so a foreign reader of a shared team could never hydrate its members. Kind `30178` embeds the member projections instead of referencing them: the share is atomic, it covers built-in members that have no `30175` head at all, it is immune to local-id/`d`-tag divergence, and an unshared `30175` stays private. Kind `30176`'s wire body is untouched, so device sync keeps its contract.
**The `d` tag is a team id, not a persona slug.** It is either a UUID or a built-in identifier such as `builtin-team:welcome`. The colon is illegal under the persona slug grammar, and rewriting ids to fit would break NIP-33 addressing against the team's own `kind:30176` head — so the relay applies a laxer rule (see below) to `30178` than to `30175`.
**Content carries only sanitized fields.** No environment variables, no `respond_to` allowlist pubkeys, no source or local ids, no filesystem paths, no secrets. Sharing a team makes the team's and every member's instructions community-readable plaintext.
## Relay behavior
### Ingest validation
@@ -226,10 +251,20 @@ Agents spawned from a persona carry [NIP-OA](NIP-OA.md) owner attestation — an
- 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.
### Ingest validation: kind:30178
Kind `30178` is stored globally and its content is unvalidated, exactly as for `30175`. The envelope rules differ in one respect — the `d` grammar:
- The relay MUST enforce the same `shared`-tag exact shape as `30175`, for the same reason: the read gate and the SQL containment clause must agree on every stored event.
- The relay MUST enforce **exactly one** `d` tag whose value is non-empty, at most 64 characters, and free of Unicode control characters and whitespace. Tags are counted by their first element, so a valueless `["d"]` counts toward the total and fails the value check on its own — otherwise `["d"]` alongside `["d","<team-id>"]` would pass, and a consumer that reads `["d"]` as an empty-valued first `d` tag would address the event at `""` while this relay addresses it at `<team-id>`. Without the non-empty check, generic NIP-33 storage maps a missing or empty `d` to the empty coordinate, collapsing every team into the single `(pubkey_o, 30178, "")` slot — last-write-wins data loss. The character bound keeps the value usable as a NIP-33 coordinate and as a log field.
- The relay MUST NOT apply the persona slug grammar to a `30178` `d` tag; team ids legitimately contain characters (notably `:`) that the slug grammar forbids.
### 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.
The gate is kind-generic: the relay applies it to every kind in `SHARED_GATED_KINDS` (`buzz-core/src/kind.rs`), currently `30175` and the `30178` team-catalog projection described below. The rules and enforcement surfaces are identical for each member kind.
**Rules:**
| Event state | Author reads | Foreign reads |
@@ -239,13 +274,13 @@ Kind `30175` uses **shared-tag-gated read semantics** to protect system prompts
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.
- **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 (`shared_gated_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.
- **COUNT** — the fast SQL `count_events()` path is bypassed when the filter can match a shared-gated kind. 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 `shared_gated_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_shared_gate_filtering` forces the per-event fallback path for any filter that can match a shared-gated kind; 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`** — no shared-gated kind is in the relay's FTS allowlist (migration 8 indexes only kinds `0, 9, 40002, 45001, 45003`); no FTS result can contain an unshared event. 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.
@@ -263,6 +298,7 @@ These rules are enforced at the following relay read surfaces (content and event
- **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.
- **No owner write authority over agents.** Persona events define *what* an agent should be; they do not grant runtime control over a running agent. The agent consumes the persona at spawn time. Updates to the persona event do not automatically propagate to running agents.
- **Sharing a team shares every member's instructions.** A `kind:30178` head carrying `["shared","true"]` exposes the team's own fields *and* the embedded projection of every member — including members whose own `kind:30175` heads are unshared and therefore still private. Clients MUST make this explicit at the point of sharing; the relay cannot infer it.
## Reference test vectors