mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
484
crates/buzz-test-client/tests/e2e_team_catalog.rs
Normal file
484
crates/buzz-test-client/tests/e2e_team_catalog.rs
Normal 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");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user