mirror of
https://github.com/block/buzz.git
synced 2026-11-04 08:21:48 +00:00
feat(core/relay): add NIP-AM kind 44200 (agent turn metrics) with relay plumbing
Add kind:44200 (KIND_AGENT_TURN_METRIC) as the durable, p-gated, owner-encrypted per-turn token-usage event defined in NIP-AM (docs/nips/NIP-AM.md, PR #1441). Changes: - buzz-core/kind.rs: add KIND_AGENT_TURN_METRIC = 44200 to P_GATED_KINDS and ALL_KINDS; compile-time asserts confirm regular (non-ephemeral, non-replaceable) kind shape - buzz-core/agent_turn_metric.rs (new): AgentTurnMetricPayload type matching the NIP schema (harness+timestamp required; nullable token fields; turn/cumulative objects; sessionId+turnSeq required when cumulative present; deltaReliable; stopReason enum); encrypt_agent_turn_metric/decrypt_agent_turn_metric helpers reusing encrypt_observer_payload/decrypt_observer_payload from observer.rs; round-trip, wrong-key, null-field, and stop-reason tests - buzz-relay/handlers/req.rs: extend p_gated_filters_authorized to deny the ids-filter exemption for kind:44200 (same carve-out shape as KIND_DM_VISIBILITY); new test covering both {kinds:[44200], ids:[...]} deny and the kindless-ids pass-through path (with documented defense-in-depth note) - buzz-relay/handlers/ingest.rs: validate_agent_turn_metric_envelope (p tag, agent tag == event.pubkey, no h tag, NIP-44 content); async is_agent_owner ownership check; required_scope_for_kind → MessagesWrite; is_global_only_kind addition; envelope and ownership tests - migrations/0001_initial_schema.sql + schema/schema.sql: add 44200 to the NULL search_tsv CASE so the p_gated_persistent_kinds_have_storage_null_tsvector drift test passes No emit logic, no adapters — Task B (goose adapter, buzz-acp) is a separate PR. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
parent
fec7684366
commit
b7480e875c
264
crates/buzz-core/src/agent_turn_metric.rs
Normal file
264
crates/buzz-core/src/agent_turn_metric.rs
Normal file
@@ -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<u64>,
|
||||
|
||||
/// Output tokens.
|
||||
pub output_tokens: Option<u64>,
|
||||
|
||||
/// Provider-reported total — NOT derived by summing input + output.
|
||||
/// `None` when the provider did not report a total.
|
||||
pub total_tokens: Option<u64>,
|
||||
|
||||
/// Estimated cost in USD. Must be finite and non-negative when present.
|
||||
pub cost_usd: Option<f64>,
|
||||
|
||||
/// Informational: cache-read tokens included in `input_tokens`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_tokens: Option<u64>,
|
||||
|
||||
/// Informational: cache-write tokens included in `input_tokens`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// Channel UUID the turn served, encrypted inside the payload.
|
||||
pub channel_id: Option<String>,
|
||||
|
||||
/// Session identifier. REQUIRED when `cumulative` is present.
|
||||
pub session_id: Option<String>,
|
||||
|
||||
/// Turn identifier (harness-internal).
|
||||
pub turn_id: Option<String>,
|
||||
|
||||
/// 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<u64>,
|
||||
|
||||
/// RFC 3339 timestamp (end-of-turn). REQUIRED.
|
||||
pub timestamp: String,
|
||||
|
||||
/// Usage for this turn (computed delta). Null fields mean not reported.
|
||||
pub turn: Option<TokenCounts>,
|
||||
|
||||
/// Session-cumulative usage as reported at end of this turn.
|
||||
pub cumulative: Option<TokenCounts>,
|
||||
|
||||
/// `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<StopReason>,
|
||||
}
|
||||
|
||||
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<String, ObserverPayloadError> {
|
||||
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<AgentTurnMetricPayload, ObserverPayloadError> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Scope, &'static s
|
||||
| KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT => {
|
||||
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<nostr::Tag> = 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user