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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -727,7 +727,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:
Expand Down
182 changes: 143 additions & 39 deletions crates/buzz-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,48 +182,67 @@ 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.
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)
!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();
Expand Down Expand Up @@ -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).
///
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand All @@ -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));
}
}
Loading