diff --git a/crates/buzz-core/src/agent_turn_metric.rs b/crates/buzz-core/src/agent_turn_metric.rs new file mode 100644 index 0000000000..325a94f9d4 --- /dev/null +++ b/crates/buzz-core/src/agent_turn_metric.rs @@ -0,0 +1,264 @@ +//! NIP-AM: Agent Turn Metric — payload type and encrypt/decrypt helpers. +//! +//! One `kind:44200` event is published per completed agent turn. Its content +//! is a NIP-44 v2 ciphertext (agent key → owner pubkey) that decodes to an +//! [`AgentTurnMetricPayload`] JSON object. +//! +//! See `docs/nips/NIP-AM.md` for the full specification. + +use nostr::{Event, Keys, PublicKey}; +use serde::{Deserialize, Serialize}; + +use crate::observer::{ + decrypt_observer_payload, encrypt_observer_payload, ObserverPayloadError, +}; + +// Re-export for callers that only need the error type. +pub use crate::observer::ObserverPayloadError as AgentTurnMetricError; + +/// Token-usage counters for a single measurement window (one turn or cumulative). +/// +/// All token fields are nullable — `None` means the harness did not report them, +/// NOT that the count was zero. See NIP-AM §Numeric validity and token semantics. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TokenCounts { + /// Input tokens (inclusive of cache reads/writes where applicable). + pub input_tokens: Option, + + /// Output tokens. + pub output_tokens: Option, + + /// Provider-reported total — NOT derived by summing input + output. + /// `None` when the provider did not report a total. + pub total_tokens: Option, + + /// Estimated cost in USD. Must be finite and non-negative when present. + pub cost_usd: Option, + + /// Informational: cache-read tokens included in `input_tokens`. + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_read_tokens: Option, + + /// Informational: cache-write tokens included in `input_tokens`. + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_write_tokens: Option, +} + +/// Why a turn ended. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StopReason { + /// Model reached a natural end-of-turn. + EndTurn, + /// Model hit the max-tokens limit. + MaxTokens, + /// Turn was cancelled by the owner or harness. + Cancelled, + /// Turn ended with an error. + Error, + /// Stop reason is unknown. + Unknown, +} + +/// Decrypted payload of a `kind:44200` Agent Turn Metric event. +/// +/// `harness` and `timestamp` are REQUIRED. All other fields are optional or +/// nullable unless constrained by the NIP (e.g. `session_id` + `turn_seq` +/// are required whenever `cumulative` is present). +/// +/// Consumers MUST ignore unknown fields (forward compatibility). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentTurnMetricPayload { + /// Harness identifier (e.g. `"goose"`, `"buzz-agent"`). REQUIRED. + pub harness: String, + + /// Model identifier as reported by the harness, or `None` if unknown. + pub model: Option, + + /// Channel UUID the turn served, encrypted inside the payload. + pub channel_id: Option, + + /// Session identifier. REQUIRED when `cumulative` is present. + pub session_id: Option, + + /// Turn identifier (harness-internal). + pub turn_id: Option, + + /// Monotonically increasing per-session sequence number. + /// REQUIRED when `cumulative` is present; strictly increasing within one + /// `session_id`. A publisher restart that loses the counter MUST start a + /// new `session_id`. + pub turn_seq: Option, + + /// RFC 3339 timestamp (end-of-turn). REQUIRED. + pub timestamp: String, + + /// Usage for this turn (computed delta). Null fields mean not reported. + pub turn: Option, + + /// Session-cumulative usage as reported at end of this turn. + pub cumulative: Option, + + /// `false` when the publisher could not observe the previous cumulative + /// baseline (e.g. harness restart mid-session), making `turn` unreliable. + /// Defaults to `true` on the wire when not explicitly set. + #[serde(default = "default_delta_reliable")] + pub delta_reliable: bool, + + /// Why the turn ended. Unrecognized values MUST be treated as `Unknown`. + pub stop_reason: Option, +} + +fn default_delta_reliable() -> bool { + true +} + +/// Encrypt an [`AgentTurnMetricPayload`] into a NIP-44 v2 ciphertext string +/// using the agent's key pair and the owner's public key. +/// +/// This is the content field of a `kind:44200` event. +pub fn encrypt_agent_turn_metric( + agent_keys: &Keys, + owner_pubkey: &PublicKey, + payload: &AgentTurnMetricPayload, +) -> Result { + encrypt_observer_payload(agent_keys, owner_pubkey, payload) +} + +/// Decrypt and deserialize an [`AgentTurnMetricPayload`] from a `kind:44200` event. +/// +/// `recipient_keys` is the owner's key pair. +pub fn decrypt_agent_turn_metric( + recipient_keys: &Keys, + event: &Event, +) -> Result { + decrypt_observer_payload(recipient_keys, event) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Kind, Tag}; + + fn sample_payload() -> AgentTurnMetricPayload { + AgentTurnMetricPayload { + harness: "goose".to_string(), + model: Some("claude-sonnet-4-5".to_string()), + channel_id: Some("12345678-1234-1234-1234-123456789abc".to_string()), + session_id: Some("sess-abc".to_string()), + turn_id: Some("turn-1".to_string()), + turn_seq: Some(1), + timestamp: "2026-07-01T20:11:03.213Z".to_string(), + turn: Some(TokenCounts { + input_tokens: Some(1234), + output_tokens: Some(567), + total_tokens: Some(1801), + cost_usd: Some(0.0123), + cache_read_tokens: None, + cache_write_tokens: None, + }), + cumulative: Some(TokenCounts { + input_tokens: Some(45210), + output_tokens: Some(9876), + total_tokens: Some(55086), + cost_usd: Some(0.41), + cache_read_tokens: None, + cache_write_tokens: None, + }), + delta_reliable: true, + stop_reason: Some(StopReason::EndTurn), + } + } + + #[test] + fn round_trip_encrypt_decrypt() { + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + + let payload = sample_payload(); + let ciphertext = encrypt_agent_turn_metric(&agent_keys, &owner_keys.public_key(), &payload) + .expect("encrypt"); + + // Build a minimal event envelope so decrypt_observer_payload can use event.pubkey. + let event = EventBuilder::new(Kind::Custom(44200), ciphertext) + .tags([ + Tag::parse(["p", &owner_keys.public_key().to_hex()]).unwrap(), + Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), + ]) + .sign_with_keys(&agent_keys) + .expect("sign"); + + let decoded = + decrypt_agent_turn_metric(&owner_keys, &event).expect("decrypt"); + + assert_eq!(decoded, payload); + } + + #[test] + fn wrong_key_decrypt_fails() { + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let wrong_keys = Keys::generate(); + + let payload = sample_payload(); + let ciphertext = encrypt_agent_turn_metric(&agent_keys, &owner_keys.public_key(), &payload) + .expect("encrypt"); + + let event = EventBuilder::new(Kind::Custom(44200), ciphertext) + .tags([ + Tag::parse(["p", &owner_keys.public_key().to_hex()]).unwrap(), + Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), + ]) + .sign_with_keys(&agent_keys) + .expect("sign"); + + let result = decrypt_agent_turn_metric(&wrong_keys, &event); + assert!(result.is_err(), "expected decrypt error with wrong key"); + } + + #[test] + fn delta_reliable_defaults_to_true_when_absent() { + let json = r#"{"harness":"goose","timestamp":"2026-07-01T20:11:03Z"}"#; + let payload: AgentTurnMetricPayload = + serde_json::from_str(json).expect("parse"); + assert!(payload.delta_reliable, "deltaReliable should default to true"); + } + + #[test] + fn stop_reason_round_trips() { + for (variant, json_val) in [ + (StopReason::EndTurn, "\"end_turn\""), + (StopReason::MaxTokens, "\"max_tokens\""), + (StopReason::Cancelled, "\"cancelled\""), + (StopReason::Error, "\"error\""), + (StopReason::Unknown, "\"unknown\""), + ] { + let serialized = serde_json::to_string(&variant).unwrap(); + assert_eq!(serialized, json_val); + let deserialized: StopReason = serde_json::from_str(json_val).unwrap(); + assert_eq!(deserialized, variant); + } + } + + #[test] + fn null_token_counts_round_trip() { + // Verify that None fields serialize to `null` (not absent), as required + // by the NIP — consumers must distinguish "not reported" from "zero". + let counts = TokenCounts { + input_tokens: None, + output_tokens: None, + total_tokens: None, + cost_usd: None, + cache_read_tokens: None, + cache_write_tokens: None, + }; + let json = serde_json::to_string(&counts).unwrap(); + // cache_* are skip_serializing_if = None, others serialize as null + assert!(json.contains("\"inputTokens\":null")); + assert!(json.contains("\"outputTokens\":null")); + let back: TokenCounts = serde_json::from_str(&json).unwrap(); + assert_eq!(back, counts); + } +} diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index f2e9184242..1da402cf17 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -131,6 +131,10 @@ pub const P_GATED_KINDS: &[u32] = &[ KIND_MEMBER_REMOVED_NOTIFICATION, KIND_GIFT_WRAP, KIND_DM_VISIBILITY, + // NIP-AM: agent turn metrics are encrypted to the owner and must not be + // readable by any unauthenticated or non-owner party, including via `ids` + // filters — see NIP-AM §Relay Behavior. + KIND_AGENT_TURN_METRIC, ]; /// NIP-AP: Agent Persona (parameterized replaceable, owner-authored). @@ -341,6 +345,15 @@ pub const KIND_MEMBER_ADDED_NOTIFICATION: u32 = 44100; /// Stored globally (channel_id = None) with p-tag = target, h-tag = channel UUID. pub const KIND_MEMBER_REMOVED_NOTIFICATION: u32 = 44101; +/// NIP-AM: Agent Turn Metric — durable per-turn token-usage record (agent-authored). +/// +/// Regular stored event (append-only, never replaced). The agent publishes one +/// event per completed turn, NIP-44 encrypted to its owner. Tags: exactly one `p` +/// (owner pubkey) and one `agent` (agent pubkey == event pubkey); no `h` tag. +/// Stored globally (channel_id = NULL); owner-scoped reads only (p-gated, NIP-42). +/// See `docs/nips/NIP-AM.md`. +pub const KIND_AGENT_TURN_METRIC: u32 = 44200; + // Forum / social (45000–45999) // V1 used addressable range (30001–30003) — wrong. /// A forum post (thread root). @@ -504,6 +517,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_JOB_ERROR, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, + KIND_AGENT_TURN_METRIC, KIND_WORKFLOW_DEF, KIND_LONG_FORM, KIND_USER_STATUS, @@ -648,6 +662,11 @@ const _: () = assert!(KIND_AUTH <= u16::MAX as u32); const _: () = assert!(KIND_CANVAS <= u16::MAX as u32); const _: () = assert!(KIND_HUDDLE_GUIDELINES <= u16::MAX as u32); const _: () = assert!(EPHEMERAL_KIND_MIN < EPHEMERAL_KIND_MAX); +// Compile-time: KIND_AGENT_TURN_METRIC is a regular stored kind (not ephemeral, not replaceable). +const _: () = assert!(!is_ephemeral(KIND_AGENT_TURN_METRIC)); +const _: () = assert!(!is_replaceable(KIND_AGENT_TURN_METRIC)); +const _: () = assert!(!is_parameterized_replaceable(KIND_AGENT_TURN_METRIC)); +const _: () = assert!(KIND_AGENT_TURN_METRIC <= u16::MAX as u32); #[cfg(test)] mod tests { diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index dee40e9887..7c3a1c38db 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -24,6 +24,8 @@ pub mod kind; pub mod network; /// Agent observer frame helpers. pub mod observer; +/// NIP-AM: Agent Turn Metric — payload type and encrypt/decrypt helpers. +pub mod agent_turn_metric; /// NIP-AB device pairing — crypto primitives, message types, and errors. pub mod pairing; /// Presence status types shared across crates. diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 057ea972f7..38e6ed95e1 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -12,7 +12,8 @@ use uuid::Uuid; use buzz_auth::Scope; use buzz_core::kind::{ event_kind_u32, is_identity_archive_request_kind, is_parameterized_replaceable, - is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_APPROVAL_DENY, + is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_AGENT_TURN_METRIC, + KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET, KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN, KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_EVENT_REMINDER, KIND_FOLLOW_SET, KIND_FORUM_COMMENT, @@ -156,6 +157,8 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result { Ok(Scope::UsersWrite) } + // NIP-AM: agent turn metrics are agent-authored global events (encrypted to owner). + KIND_AGENT_TURN_METRIC => Ok(Scope::MessagesWrite), // NIP-51 standard lists and NIP-65 relay list — user-owned global state, // same ownership shape as kind:3 (contacts) and kind:0 (profile). KIND_MUTE_LIST @@ -376,6 +379,9 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { // Mesh-LLM relay status is relay-signed and global. Clients may // subscribe to it, but must not channel-scope or submit it. | KIND_MESH_LLM_RELAY_STATUS + // NIP-AM: agent turn metrics are owner-scoped global events. + // Channel identity is encrypted inside the payload — no `h` tag. + | KIND_AGENT_TURN_METRIC ) } @@ -1055,6 +1061,78 @@ fn validate_engram_nip44_content(content: &str) -> Result<(), String> { Ok(()) } +/// Validate the public envelope of a NIP-AM `kind:44200` event. +/// +/// Enforces (without touching the encrypted payload): +/// - Exactly one `p` tag: 64 lowercase hex chars (the owner pubkey). +/// - Exactly one `agent` tag: 64 lowercase hex chars equal to `event.pubkey`. +/// - No `h` tag (channel identity belongs inside the encrypted payload). +/// - Content syntactically resembles NIP-44 v2 ciphertext (delegated to +/// `validate_engram_nip44_content`, which does the same length/base64/version check). +/// +/// Ownership (`is_agent_owner`) is an async DB check performed separately in +/// `ingest_event_inner` after this synchronous envelope check. +fn validate_agent_turn_metric_envelope(event: &nostr::Event) -> Result<(), String> { + let event_pubkey_hex = event.pubkey.to_hex(); + let mut p_tags: Vec<&str> = Vec::new(); + let mut agent_tags: Vec<&str> = Vec::new(); + let mut has_h_tag = false; + + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.len() < 2 { + continue; + } + match parts[0].as_str() { + "p" => p_tags.push(&parts[1]), + "agent" => agent_tags.push(&parts[1]), + "h" => has_h_tag = true, + _ => {} + } + } + + if has_h_tag { + return Err( + "agent-turn-metric event must not have an `h` tag (channel identity belongs inside the encrypted payload)".to_string(), + ); + } + + if p_tags.len() != 1 { + return Err(format!( + "agent-turn-metric event must have exactly one `p` tag (got {})", + p_tags.len() + )); + } + let p = p_tags[0]; + if p.len() != 64 || !p.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) { + return Err("agent-turn-metric `p` tag must be 64 lowercase hex chars".to_string()); + } + + if agent_tags.len() != 1 { + return Err(format!( + "agent-turn-metric event must have exactly one `agent` tag (got {})", + agent_tags.len() + )); + } + let agent = agent_tags[0]; + if agent.len() != 64 + || !agent.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err("agent-turn-metric `agent` tag must be 64 lowercase hex chars".to_string()); + } + if agent != event_pubkey_hex { + return Err( + "agent-turn-metric `agent` tag must equal event pubkey".to_string(), + ); + } + + // Content must look like a NIP-44 v2 ciphertext (length, base64, version prefix). + validate_engram_nip44_content(&event.content) + .map_err(|e| e.replace("agent-engram", "agent-turn-metric"))?; + + Ok(()) +} + /// Parse a NIP-ER `not_before` tag value into a Unix timestamp. /// /// The value MUST be a decimal integer string containing only ASCII digits, with @@ -1622,6 +1700,43 @@ async fn ingest_event_inner( .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; } + if kind_u32 == KIND_AGENT_TURN_METRIC { + validate_agent_turn_metric_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + + // Ownership check: `p` tag must be the registered owner of `event.pubkey`. + // Tag shape is already verified above; these extractions are infallible. + let owner_hex = event + .tags + .iter() + .find_map(|t| { + let parts = t.as_slice(); + if parts.len() >= 2 && parts[0].as_str() == "p" { + Some(parts[1].as_str()) + } else { + None + } + }) + .expect("p tag present (validated above)"); + let agent_bytes = event.pubkey.to_bytes().to_vec(); + let owner_bytes = hex::decode(owner_hex).expect("hex validated above"); + let is_owner = state + .db + .is_agent_owner(tenant.community(), &agent_bytes, &owner_bytes) + .await + .map_err(|e| { + IngestError::Internal(format!( + "error: db error checking agent-turn-metric ownership: {e}" + )) + })?; + if !is_owner { + return Err(IngestError::AuthFailed( + "restricted: agent-turn-metric `p` tag must be the registered owner of this agent" + .into(), + )); + } + } + if kind_u32 == KIND_EVENT_REMINDER { validate_event_reminder(&event) .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; @@ -2260,6 +2375,7 @@ mod tests { KIND_PERSONA, KIND_TEAM, KIND_MANAGED_AGENT, + KIND_AGENT_TURN_METRIC, ]; for kind in migrated { assert!( @@ -2297,6 +2413,24 @@ mod tests { assert!(!requires_h_channel_scope(KIND_MESH_LLM_RELAY_STATUS)); } + #[test] + fn agent_turn_metric_is_global_only_and_in_scope_allowlist() { + let dummy = make_dummy_event(); + assert!( + is_global_only_kind(KIND_AGENT_TURN_METRIC), + "kind:44200 must be global-only (no h tag)" + ); + assert!( + !requires_h_channel_scope(KIND_AGENT_TURN_METRIC), + "kind:44200 must not require an h-tag" + ); + assert_eq!( + required_scope_for_kind(KIND_AGENT_TURN_METRIC, &dummy).unwrap(), + Scope::MessagesWrite, + "kind:44200 requires MessagesWrite scope" + ); + } + #[test] fn nip51_and_nip65_lists_are_global_only() { for kind in [ @@ -2930,4 +3064,112 @@ mod tests { let err = validate_persona_envelope(&ev).unwrap_err(); assert!(err.contains("`d` tag"), "got: {err}"); } + + // ─── agent_turn_metric envelope tests ──────────────────────────────────── + + /// Build an event for kind:44200 with the given tags and content. + /// The signing key IS the agent key, so `event.pubkey` matches the agent. + fn make_agent_turn_metric( + agent_keys: &nostr::Keys, + tags: &[&[&str]], + content: &str, + ) -> nostr::Event { + let nostr_tags: Vec = tags + .iter() + .map(|t| nostr::Tag::parse(t.iter().copied()).unwrap()) + .collect(); + nostr::EventBuilder::new( + nostr::Kind::Custom(buzz_core::kind::KIND_AGENT_TURN_METRIC as u16), + content, + ) + .tags(nostr_tags) + .sign_with_keys(agent_keys) + .unwrap() + } + + #[test] + fn agent_turn_metric_envelope_accepts_canonical() { + let agent = nostr::Keys::generate(); + let owner_hex = "b".repeat(64); + let agent_hex = agent.public_key().to_hex(); + let ev = make_agent_turn_metric( + &agent, + &[&["p", &owner_hex], &["agent", &agent_hex]], + &fake_nip44_v2(), + ); + assert!(validate_agent_turn_metric_envelope(&ev).is_ok()); + } + + #[test] + fn agent_turn_metric_envelope_rejects_h_tag() { + let agent = nostr::Keys::generate(); + let owner_hex = "b".repeat(64); + let agent_hex = agent.public_key().to_hex(); + let ev = make_agent_turn_metric( + &agent, + &[ + &["p", &owner_hex], + &["agent", &agent_hex], + &["h", "some-channel-uuid"], + ], + &fake_nip44_v2(), + ); + let err = validate_agent_turn_metric_envelope(&ev).unwrap_err(); + assert!(err.contains("`h` tag"), "got: {err}"); + } + + #[test] + fn agent_turn_metric_envelope_rejects_missing_p() { + let agent = nostr::Keys::generate(); + let agent_hex = agent.public_key().to_hex(); + let ev = + make_agent_turn_metric(&agent, &[&["agent", &agent_hex]], &fake_nip44_v2()); + let err = validate_agent_turn_metric_envelope(&ev).unwrap_err(); + assert!(err.contains("`p` tag"), "got: {err}"); + } + + #[test] + fn agent_turn_metric_envelope_rejects_missing_agent() { + let agent = nostr::Keys::generate(); + let owner_hex = "b".repeat(64); + let ev = + make_agent_turn_metric(&agent, &[&["p", &owner_hex]], &fake_nip44_v2()); + let err = validate_agent_turn_metric_envelope(&ev).unwrap_err(); + assert!(err.contains("`agent` tag"), "got: {err}"); + } + + #[test] + fn agent_turn_metric_envelope_rejects_agent_mismatch() { + let agent = nostr::Keys::generate(); + let owner_hex = "b".repeat(64); + let wrong_agent_hex = "c".repeat(64); // not event.pubkey + let ev = make_agent_turn_metric( + &agent, + &[&["p", &owner_hex], &["agent", &wrong_agent_hex]], + &fake_nip44_v2(), + ); + let err = validate_agent_turn_metric_envelope(&ev).unwrap_err(); + assert!( + err.contains("equal event pubkey"), + "got: {err}" + ); + } + + #[test] + fn agent_turn_metric_envelope_rejects_bad_content() { + let agent = nostr::Keys::generate(); + let owner_hex = "b".repeat(64); + let agent_hex = agent.public_key().to_hex(); + let ev = make_agent_turn_metric( + &agent, + &[&["p", &owner_hex], &["agent", &agent_hex]], + "not-a-ciphertext", + ); + let err = validate_agent_turn_metric_envelope(&ev).unwrap_err(); + // error comes from validate_engram_nip44_content with label replaced + assert!( + err.contains("agent-turn-metric"), + "got: {err}" + ); + } } diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 2e697eafa2..b43c04bcaa 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -6,7 +6,10 @@ use std::sync::Arc; use tracing::{debug, warn}; use buzz_core::filter::filters_match; -use buzz_core::kind::{AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_DM_VISIBILITY, P_GATED_KINDS}; +use buzz_core::kind::{ + AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC, KIND_DM_VISIBILITY, + P_GATED_KINDS, +}; use buzz_core::tenant::TenantContext; use buzz_db::EventQuery; use buzz_pubsub::EventTopic; @@ -974,13 +977,18 @@ pub(crate) fn p_gated_filters_authorized(filters: &[Filter], authed_pubkey_hex: // safe for kinds whose id is author-bound or whose content is encrypted. // KIND_DM_VISIBILITY is relay-signed (id not author-bound) and exposes // plaintext private hide choices, so its `#p` owner check MUST hold even - // when `ids` is present. Only filters that explicitly name the kind lose - // the exemption — a kindless `ids` lookup is unaffected. - let explicitly_dm_visibility = filter.kinds.as_ref().is_some_and(|ks| { - ks.iter() - .any(|kind| kind.as_u16() as u32 == KIND_DM_VISIBILITY) + // when `ids` is present. KIND_AGENT_TURN_METRIC events are long-lived + // and their cleartext envelope (pubkey, agent tag, created_at) leaks + // turn-activity metadata — knowing an event id is NOT authorization + // (NIP-AM §Relay Behavior). Only filters that explicitly name the kind + // lose the exemption — a kindless `ids` lookup is unaffected. + let explicitly_no_ids_exemption = filter.kinds.as_ref().is_some_and(|ks| { + ks.iter().any(|kind| { + let k = kind.as_u16() as u32; + k == KIND_DM_VISIBILITY || k == KIND_AGENT_TURN_METRIC + }) }); - if !explicitly_dm_visibility && filter.ids.as_ref().is_some_and(|ids| !ids.is_empty()) { + if !explicitly_no_ids_exemption && filter.ids.as_ref().is_some_and(|ids| !ids.is_empty()) { return true; } @@ -1284,6 +1292,66 @@ mod tests { assert!(p_gated_filters_authorized(&[member_notif_ids], authed)); } + /// NIP-AM: kind 44200 must deny `{kinds:[44200], ids:[...]}` by non-owner. + /// Thufir's implementation note: the helper treats explicit-kind+ids and + /// kindless ids differently. Explicit `{kinds:[44200], ids:[...]}` is denied; + #[test] + fn agent_turn_metric_requires_p_tag_even_with_ids() { + let p_tag = SingleLetterTag::lowercase(Alphabet::P); + let authed = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let other = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let event_id = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + let metric_kind = nostr::Kind::Custom(buzz_core::kind::KIND_AGENT_TURN_METRIC as u16); + + // Case 1: {kinds:[44200], ids:[...]} — explicit kind, should require #p owner. + let explicit_kind_ids_only = Filter::new() + .kind(metric_kind) + .id(nostr::EventId::from_hex(event_id).unwrap()); + assert!( + !p_gated_filters_authorized(&[explicit_kind_ids_only], authed), + "kind:44200 + ids without matching #p must be denied" + ); + + let explicit_kind_wrong_p = Filter::new() + .kind(metric_kind) + .id(nostr::EventId::from_hex(event_id).unwrap()) + .custom_tags(p_tag, [other]); + assert!( + !p_gated_filters_authorized(&[explicit_kind_wrong_p], authed), + "kind:44200 + ids + wrong #p must be denied" + ); + + // Case 2: kindless {ids:[...]} — the existing ids exemption applies + // (consistent with other p-gated kinds like member notifications). The + // relay's defense-in-depth for kind:44200 is: (a) the explicit-kind+ids + // carve-out above, (b) NULL tsvector storage preventing search discovery, + // and (c) the subscription delivery layer not returning 44200 events to + // non-owners. A kindless ids filter is authorized here because + // p_gated_filters_authorized cannot know which kind the id resolves to. + let kindless_ids = Filter::new().id(nostr::EventId::from_hex(event_id).unwrap()); + assert!( + p_gated_filters_authorized(&[kindless_ids], authed), + "kindless ids filter passes this gate (consistent with member-notif behavior)" + ); + + // Case 3: owner querying by #p is allowed. + let owner_by_p = Filter::new().kind(metric_kind).custom_tags(p_tag, [authed]); + assert!( + p_gated_filters_authorized(&[owner_by_p], authed), + "kind:44200 with matching #p must be allowed" + ); + + // Case 4: owner querying by #p + ids is allowed. + let owner_p_and_ids = Filter::new() + .kind(metric_kind) + .id(nostr::EventId::from_hex(event_id).unwrap()) + .custom_tags(p_tag, [authed]); + assert!( + p_gated_filters_authorized(&[owner_p_and_ids], authed), + "kind:44200 with matching #p and ids must be allowed" + ); + } + #[test] fn test_mixed_search_and_non_search_detection() { let search_filter = Filter::new().search("hello"); diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql index 2d4035f8a5..a653de3c02 100644 --- a/migrations/0001_initial_schema.sql +++ b/migrations/0001_initial_schema.sql @@ -211,16 +211,18 @@ CREATE TABLE events ( -- 30622 = KIND_DM_VISIBILITY (per-viewer private hide state) -- 44100 = KIND_MEMBER_ADDED_NOTIFICATION (p-gated membership notice) -- 44101 = KIND_MEMBER_REMOVED_NOTIFICATION (p-gated membership notice) + -- 44200 = KIND_AGENT_TURN_METRIC (NIP-AM: p-gated encrypted turn metrics) -- NULL tsvector never matches `@@`, so excluded rows are storage-level -- unsearchable. Constants kept in `buzz_core::kind` (KIND_GIFT_WRAP, -- KIND_EVENT_REMINDER, KIND_DM_VISIBILITY, - -- KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION); inlined + -- KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, + -- KIND_AGENT_TURN_METRIC); inlined -- here because a sqlx -- migration is frozen SQL and cannot import the Rust constant. If a new -- privacy-sensitive kind is added there, update this list and add a -- regression test in `buzz-search/tests/fts_integration.rs`. search_tsv TSVECTOR GENERATED ALWAYS AS ( - CASE WHEN kind IN (1059, 30300, 30622, 44100, 44101) THEN NULL::tsvector + CASE WHEN kind IN (1059, 30300, 30622, 44100, 44101, 44200) THEN NULL::tsvector ELSE to_tsvector('simple', content) END ) STORED, diff --git a/schema/schema.sql b/schema/schema.sql index 1c6fcfb985..c3247f103f 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -208,7 +208,7 @@ CREATE TABLE events ( -- never matches `@@`. -- Keep in sync with migrations/0001_initial_schema.sql. search_tsv TSVECTOR GENERATED ALWAYS AS ( - CASE WHEN kind IN (1059, 30300, 30622, 44100, 44101) THEN NULL::tsvector + CASE WHEN kind IN (1059, 30300, 30622, 44100, 44101, 44200) THEN NULL::tsvector ELSE to_tsvector('simple', content) END ) STORED,