mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
Kind 30175 persona sync events carry plaintext `system_prompt` and
`respond_to_allowlist`. This PR adds **author-only-unless-shared read
semantics**: events without `["shared","true"]` are visible only to the
author; events with that tag are community-readable.
## What changed
### New read class (kind 30175)
Kind 30175 gets per-event gating at every relay read surface. The
`shared` marker is a **tag**, not a content field, so content bytes
(which double as the `source_version` drift basis) are not affected when
toggling share state.
### `event_visible_to_reader` helper (`handlers/req.rs`)
Centralizes the three per-event access predicates —
`is_author_only_event`, `is_unshared_persona_event`,
`reader_authorized_for_event` — into one `pub(crate)` fn callable from
both WS and HTTP adapters. All result-visibility sites now call this
single helper.
### NIP-98 HTTP bridge (`api/bridge.rs`)
- `POST /query` catchall: replaced the two-step author-only +
result-gated checks with `event_visible_to_reader` (now also covers the
persona shared-gate).
- `POST /count`: added `needs_persona_filtering` to the fast-path guard
(forces per-event fallback when filter can match `kind:30175`) and
replaced both fallback loops' individual checks with
`event_visible_to_reader`.
- FTS `/search` bridge helper: replaced `is_author_only_event` with
`event_visible_to_reader` as defense-in-depth (30175 is not in the FTS
allowlist today; comment at site explains the future-proofing intent).
### Ingest validation (`handlers/ingest.rs`)
`validate_persona_envelope` rejects malformed `shared` tags: wrong
value, missing value, duplicates. Accepts exactly `["shared","true"]`
and tag-absent.
### Kind helpers (`buzz-core/src/kind.rs`)
`is_persona_shared_kind`, `is_unshared_persona_event`,
`filter_can_match_persona_shared_kinds`.
### Tests (`e2e_persona.rs`)
8 unit tests in `kind.rs`, 6 in `ingest.rs`, 8 e2e tests total:
- AC-1–6 covering the gated surfaces
- `test_persona_live_fanout_shared_gate`: reworked with explicit
monotonic `created_at` timestamps (t0 < t1 < t2) and per-step head
assertions, eliminating the NIP-33 event-id tie-break race. Also asserts
foreign live subscription receives nothing on shared→unshared
transition.
- `test_persona_ingest_shared_tag_validation`: added `shared=x` and
missing-value wire-level rejection cases.
- `test_persona_mixed_kind_filter_does_not_leak`: publishes a kind-9
event and asserts it IS returned; absence-only assertion no longer
sufficient.
- `test_persona_http_query_cross_author_gate`: NIP-98 `/query`
cross-author gate (authors filter, kindless `ids` — both blocked; shared
`ids` — passes).
- `test_persona_http_count_cross_author_gate`: NIP-98 `/count`
cross-author gate (foreign sees 1/shared, author sees all, wildcard
checked).
### NIP-AP.md
Replaced aspirational "every relay read chokepoint" wording with an
enumerated list of gated surfaces including NIP-98 `/query`, `/count`,
and FTS/search with their enforcement mechanism named. Added
**Non-goal** note for side-band existence oracles
(reaction/report/deletion target resolution).
## Existing tests
All pre-existing `e2e_persona` tests use `{ids:[event_id]}` or
`{authors:[self]}` filters — author self-reads bypass the gate and are
unaffected.
## Gates
`just check` ✅ | `just test-unit` ✅ | `cargo test -p buzz-relay` ✅ (749
passed, 1 pre-existing failure in
`demo_join_forwarded_arm_round_trips_echo` — flaky on `main`, unrelated
to this PR, verified red at `origin/main` before this branch)
---------
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
191 lines
6.1 KiB
Rust
191 lines
6.1 KiB
Rust
use nostr::{Event, EventBuilder, Keys, RelayUrl, Tag};
|
|
use serde_json::Value;
|
|
|
|
use crate::error::WsClientError;
|
|
|
|
/// A message received from a Nostr relay.
|
|
#[derive(Debug, Clone)]
|
|
pub enum RelayMessage {
|
|
/// An event matching an active subscription.
|
|
Event {
|
|
/// The subscription ID this event belongs to.
|
|
subscription_id: String,
|
|
/// The Nostr event payload.
|
|
event: Box<Event>,
|
|
},
|
|
/// Acknowledgement of a published event.
|
|
Ok(OkResponse),
|
|
/// End-of-stored-events marker for a subscription.
|
|
Eose {
|
|
/// The subscription ID that has reached end-of-stored-events.
|
|
subscription_id: String,
|
|
},
|
|
/// The relay closed a subscription, usually with an error.
|
|
Closed {
|
|
/// The subscription ID that was closed.
|
|
subscription_id: String,
|
|
/// Human-readable reason for the closure.
|
|
message: String,
|
|
},
|
|
/// A human-readable notice from the relay.
|
|
Notice {
|
|
/// The notice text.
|
|
message: String,
|
|
},
|
|
/// A NIP-42 authentication challenge from the relay.
|
|
Auth {
|
|
/// The challenge string to sign.
|
|
challenge: String,
|
|
},
|
|
/// A NIP-45 COUNT response.
|
|
Count {
|
|
/// The subscription ID this count belongs to.
|
|
subscription_id: String,
|
|
/// The number of matching events.
|
|
count: u64,
|
|
},
|
|
}
|
|
|
|
/// The relay's response to a published event (NIP-01 `OK` message).
|
|
#[derive(Debug, Clone)]
|
|
pub struct OkResponse {
|
|
/// Hex-encoded ID of the event that was acknowledged.
|
|
pub event_id: String,
|
|
/// Whether the relay accepted the event.
|
|
pub accepted: bool,
|
|
/// Human-readable reason string (empty when accepted without comment).
|
|
pub message: String,
|
|
}
|
|
|
|
/// Parse a raw relay text frame into a typed [`RelayMessage`].
|
|
#[allow(clippy::result_large_err)]
|
|
pub fn parse_relay_message(text: &str) -> Result<RelayMessage, WsClientError> {
|
|
let arr: Vec<Value> = serde_json::from_str(text)?;
|
|
|
|
let msg_type = arr
|
|
.first()
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| WsClientError::UnexpectedMessage(text.to_string()))?;
|
|
|
|
match msg_type {
|
|
"EVENT" => {
|
|
let sub_id = arr
|
|
.get(1)
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| WsClientError::UnexpectedMessage(text.to_string()))?
|
|
.to_string();
|
|
let event: Event = serde_json::from_value(
|
|
arr.get(2)
|
|
.cloned()
|
|
.ok_or_else(|| WsClientError::UnexpectedMessage(text.to_string()))?,
|
|
)?;
|
|
Ok(RelayMessage::Event {
|
|
subscription_id: sub_id,
|
|
event: Box::new(event),
|
|
})
|
|
}
|
|
"OK" => {
|
|
let event_id = arr
|
|
.get(1)
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| WsClientError::UnexpectedMessage(text.to_string()))?
|
|
.to_string();
|
|
let accepted = arr.get(2).and_then(|v| v.as_bool()).unwrap_or(false);
|
|
let message = arr
|
|
.get(3)
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
Ok(RelayMessage::Ok(OkResponse {
|
|
event_id,
|
|
accepted,
|
|
message,
|
|
}))
|
|
}
|
|
"EOSE" => {
|
|
let sub_id = arr
|
|
.get(1)
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| WsClientError::UnexpectedMessage(text.to_string()))?
|
|
.to_string();
|
|
Ok(RelayMessage::Eose {
|
|
subscription_id: sub_id,
|
|
})
|
|
}
|
|
"CLOSED" => {
|
|
let sub_id = arr
|
|
.get(1)
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| WsClientError::UnexpectedMessage(text.to_string()))?
|
|
.to_string();
|
|
let message = arr
|
|
.get(2)
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
Ok(RelayMessage::Closed {
|
|
subscription_id: sub_id,
|
|
message,
|
|
})
|
|
}
|
|
"NOTICE" => {
|
|
let message = arr
|
|
.get(1)
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
Ok(RelayMessage::Notice { message })
|
|
}
|
|
"AUTH" => {
|
|
let challenge = arr
|
|
.get(1)
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| WsClientError::UnexpectedMessage(text.to_string()))?
|
|
.to_string();
|
|
Ok(RelayMessage::Auth { challenge })
|
|
}
|
|
"COUNT" => {
|
|
let sub_id = arr
|
|
.get(1)
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| WsClientError::UnexpectedMessage(text.to_string()))?
|
|
.to_string();
|
|
let count = arr
|
|
.get(2)
|
|
.and_then(|o| o.get("count"))
|
|
.and_then(|c| c.as_u64())
|
|
.ok_or_else(|| WsClientError::UnexpectedMessage(text.to_string()))?;
|
|
Ok(RelayMessage::Count {
|
|
subscription_id: sub_id,
|
|
count,
|
|
})
|
|
}
|
|
other => Err(WsClientError::UnexpectedMessage(format!(
|
|
"unknown message type: {other}"
|
|
))),
|
|
}
|
|
}
|
|
|
|
/// Builds a NIP-42 AUTH event, optionally injecting a NIP-OA auth tag.
|
|
///
|
|
/// The `auth_tag` parameter allows callers to attach a workspace-scoped
|
|
/// authorization tag (e.g. `["auth", "<token>"]`) alongside the standard
|
|
/// relay and challenge tags required by NIP-42.
|
|
pub fn build_auth_event(
|
|
challenge: &str,
|
|
relay_url: &str,
|
|
keys: &Keys,
|
|
auth_tag: Option<&Tag>,
|
|
) -> Result<Event, WsClientError> {
|
|
let url = RelayUrl::parse(relay_url).map_err(|e| WsClientError::Url(e.to_string()))?;
|
|
let builder = EventBuilder::auth(challenge, url);
|
|
let builder = if let Some(tag) = auth_tag {
|
|
builder.tags([tag.clone()])
|
|
} else {
|
|
builder
|
|
};
|
|
builder
|
|
.sign_with_keys(keys)
|
|
.map_err(|e| WsClientError::EventBuilder(e.to_string()))
|
|
}
|