diff --git a/.env.example b/.env.example index 5c9cd59a1c0..610aaa73ac3 100644 --- a/.env.example +++ b/.env.example @@ -83,6 +83,13 @@ RELAY_URL=ws://localhost:3000 # Containerized deployments need an explicitly protected internal transport or # host-network adjustment; do not expose the integration listener publicly. +# Signed reputation evidence provider (disabled | local | dkg). +# `local` needs no DKG service: it resolves channel-scoped NIP-32 vouches and +# viewer-selected NIP-85 assertions from this relay's event store. If omitted, +# deployments with BUZZ_DKG_TRUST_ENABLED=true keep using `dkg`; all other +# relays default to disabled. +# BUZZ_REPUTATION_PROVIDER=local + # ----------------------------------------------------------------------------- # Git (NIP-34 bare repositories) # ----------------------------------------------------------------------------- diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 07ab2fcb681..a846ffd75fb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -620,7 +620,7 @@ pub enum AuthState { Pending { challenge: String }, Authenticated(AuthContext), | POST | `/events` | Submit a signed Nostr event over HTTP (same ingest path as WebSocket `EVENT`) | | POST | `/query` | Query Nostr events over HTTP with NIP-01 filters | | POST | `/count` | Count Nostr events over HTTP with NIP-45 filters | -| POST | `/api/dkg/query` | Feature-configured, NIP-98-authenticated front for constrained DKG graph reads | +| POST | `/api/dkg/query` | Feature-configured, NIP-98-authenticated front for constrained graph and reputation reads | | POST | `/hooks/{id}` | Workflow webhook trigger (secret-authenticated) | | PUT | `/media/upload` | Upload media blob (Blossom, 50 MB limit) | | GET/HEAD | `/media/{sha256_ext}` | Retrieve/probe media blob | @@ -632,20 +632,30 @@ pub enum AuthState { Pending { challenge: String }, Authenticated(AuthContext), The DKG query front is an intentional HTTP exception to Buzz's Nostr-first API. Graph, triple, trail, and evidence results can be large request/response reads; persisting them as relay events would add irrelevant history and fan-out. The -route is absent unless both `BUZZ_DKG_QUERY_URL` and `BUZZ_DKG_QUERY_TOKEN` are -configured. Before forwarding it binds the tenant from `Host`, verifies NIP-98 +route is absent unless either a DKG gateway is configured or +`BUZZ_REPUTATION_PROVIDER=local` enables relay-local signed evidence reads. +Before resolving it binds the tenant from `Host`, verifies NIP-98 against the exact URL and body payload, applies shared HTTP admission and replay protection, enforces relay membership, and applies the relay's canonical tenant-scoped accessible-channel check. Only allowlisted operation-specific arguments plus the authenticated requester pubkey reach the internal integration. Request bodies, response bodies, redirects, and total request time are bounded; callers cannot provide a context-graph id, SPARQL, DKG endpoint, or -DKG credential. +DKG credential. The local provider has an independent two-second deadline, +returns at most 100 normalized `buzz-trust-claim@1` claims per page, and never +contacts relay hints supplied by an event. It resolves channel-scoped NIP-32 +vouches plus only the exact NIP-85 `kind:result-tag` sources selected by the +authenticated viewer's public kind:10040 event. Encrypted or unresolved +external source selections produce an explicit `partial` result rather than an +empty or overclaimed result. Write support is advertised and routed only when `BUZZ_DKG_MEMORY_ENABLED=true`. The optional `dkg-trust@1` profile and its trust/reputation operations additionally require `BUZZ_DKG_TRUST_ENABLED=true`; this prevents a relay paired with an older integration build from claiming support it does not have. +When that legacy trust flag is present, the DKG reputation provider remains the +default. Operators can select `BUZZ_REPUTATION_PROVIDER=local` independently, +including on a Buzz-only relay; `disabled` is the default everywhere else. **Constants:** diff --git a/crates/buzz-relay/src/api/dkg_query.rs b/crates/buzz-relay/src/api/dkg_query.rs index b989c26dd27..5fd6eac6f86 100644 --- a/crates/buzz-relay/src/api/dkg_query.rs +++ b/crates/buzz-relay/src/api/dkg_query.rs @@ -99,6 +99,19 @@ struct SemanticQueryArguments { #[serde(deny_unknown_fields)] struct EmptyArguments {} +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct TrustNetworkArguments { + #[serde(skip_serializing_if = "Option::is_none")] + limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + cursor: Option, + #[serde(skip_serializing_if = "Option::is_none")] + since: Option, + #[serde(skip_serializing_if = "Option::is_none")] + until: Option, +} + #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct PubkeyArguments { @@ -239,7 +252,36 @@ fn parse_and_sanitize_request( serde_json::to_value(arguments) } Operation::TrustNetwork => { - let arguments: EmptyArguments = parse_arguments(request.arguments)?; + let arguments: TrustNetworkArguments = parse_arguments(request.arguments)?; + if arguments + .limit + .is_some_and(|limit| !(1..=100).contains(&limit)) + { + return Err(api_error( + StatusCode::BAD_REQUEST, + "arguments.limit must be in 1..=100", + )); + } + if arguments + .cursor + .as_ref() + .is_some_and(|cursor| cursor.is_empty() || cursor.len() > 256) + { + return Err(api_error( + StatusCode::BAD_REQUEST, + "arguments.cursor must contain 1..=256 bytes", + )); + } + if arguments + .since + .zip(arguments.until) + .is_some_and(|(since, until)| since > until) + { + return Err(api_error( + StatusCode::BAD_REQUEST, + "arguments.since must not be later than arguments.until", + )); + } serde_json::to_value(arguments) } Operation::ReputationSummary => { @@ -374,12 +416,6 @@ pub async fn query( headers: HeaderMap, body: axum::body::Bytes, ) -> Result<(StatusCode, Json), (StatusCode, Json)> { - let config = state - .config - .dkg_query - .as_ref() - .ok_or_else(|| not_found("not found"))?; - let raw_host = headers .get(axum::http::header::HOST) .and_then(|value| value.to_str().ok()) @@ -424,11 +460,24 @@ pub async fn query( ) { let request = serde_json::to_value(&forward) .map_err(|_| internal_error("serializing reputation-provider request"))?; - let provider = - ConfiguredReputationProvider::from_dkg_config(state.config.dkg_query.as_ref()); + let provider = ConfiguredReputationProvider::from_config( + state.config.reputation_provider, + state.config.dkg_query.as_ref(), + &state.db, + tenant.community(), + forward.channel_id, + requester_bytes.to_vec(), + &state.config.relay_url, + ); return provider.attestations(&request).await.into_http_result(); } + let config = state + .config + .dkg_query + .as_ref() + .ok_or_else(|| not_found("not found"))?; + let client = HTTP_CLIENT .as_ref() .map_err(|_| internal_error("initializing the internal DKG query client"))?; @@ -562,6 +611,46 @@ mod tests { } } + #[test] + fn trust_query_bounds_pagination_and_time_window() { + let requester = requester(); + let sanitized = parse_and_sanitize_request( + &request( + "trust_network", + serde_json::json!({ + "limit": 25, + "cursor": format!("1700000000:{}", "a".repeat(64)), + "since": 1_600_000_000, + "until": 1_800_000_000, + }), + ), + &requester, + ) + .expect("bounded trust query"); + assert_eq!(sanitized.arguments["limit"], 25); + assert!(parse_and_sanitize_request( + &request("trust_network", serde_json::json!({"limit": 101})), + &requester, + ) + .is_err()); + assert!(parse_and_sanitize_request( + &request( + "trust_network", + serde_json::json!({"since": 20, "until": 10}) + ), + &requester, + ) + .is_err()); + assert!(parse_and_sanitize_request( + &request( + "trust_network", + serde_json::json!({"cursor": "x".repeat(257)}) + ), + &requester, + ) + .is_err()); + } + #[test] fn accepts_only_current_channel_semantic_queries_and_sanitizes_defaults() { let requester = requester(); diff --git a/crates/buzz-relay/src/api/reputation_provider.rs b/crates/buzz-relay/src/api/reputation_provider.rs index 82d3a9e7686..edabd2d078d 100644 --- a/crates/buzz-relay/src/api/reputation_provider.rs +++ b/crates/buzz-relay/src/api/reputation_provider.rs @@ -7,13 +7,20 @@ //! clients remain compatible while reputation callers gain explicit //! resolution semantics. +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::time::Duration; + use async_trait::async_trait; use axum::{http::StatusCode, response::Json}; -use chrono::{DateTime, Utc}; -use serde::Serialize; -use serde_json::{Map, Value}; +use chrono::{DateTime, TimeZone, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use uuid::Uuid; + +use buzz_core::{CommunityId, StoredEvent}; +use buzz_db::{Db, EventQuery}; -use crate::config::DkgQueryConfig; +use crate::config::{DkgQueryConfig, ReputationProviderKind}; use super::{api_error, dkg_query}; @@ -39,7 +46,7 @@ pub enum ResolutionState { #[serde(rename_all = "camelCase")] pub struct SourceDiagnostic { /// Stable identifier for the contributing source. - pub source_id: &'static str, + pub source_id: String, /// Resolution reached by this source. pub resolution: ResolutionState, /// Safe, human-readable reason for a non-complete result. @@ -82,7 +89,7 @@ impl ReputationBatch { provider_version: "dkg-trust@1", as_of: Utc::now(), source_diagnostics: vec![SourceDiagnostic { - source_id: "origintrail-dkg", + source_id: "origintrail-dkg".to_string(), resolution, detail, }], @@ -90,6 +97,21 @@ impl ReputationBatch { } } + fn local( + resolution: ResolutionState, + response: ApiResponse, + source_diagnostics: Vec, + ) -> Self { + Self { + resolution, + provider_id: "buzz-relay-local", + provider_version: "buzz-trust-claim@1", + as_of: Utc::now(), + source_diagnostics, + response: Some(response), + } + } + fn metadata(&self) -> Map { let mut metadata = Map::new(); metadata.insert( @@ -168,6 +190,764 @@ impl ReputationProvider for NullProvider { } } +const LOCAL_QUERY_DEADLINE: Duration = Duration::from_secs(2); +const LOCAL_EVENT_LIMIT: i64 = 500; +const LOCAL_SOURCE_LIMIT: usize = 32; +const DEFAULT_CLAIM_LIMIT: usize = 100; +const MAX_CLAIM_LIMIT: usize = 100; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LocalRequest { + operation: String, + #[serde(default)] + arguments: Value, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct TrustQueryArguments { + limit: Option, + cursor: Option, + since: Option, + until: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct SelectedSource { + result_tag: String, + pubkey: String, + relay: Option, +} + +#[derive(Debug)] +struct NormalizedClaim { + created_at: u64, + event_id: String, + issuer: String, + subject: String, + claim_type: &'static str, + value: Value, +} + +#[derive(Debug)] +struct LocalTrustResult { + response: ApiResponse, + resolution: ResolutionState, + diagnostics: Vec, +} + +/// Provider that resolves signed trust evidence from the current community's +/// bounded relay-local event store. It never opens arbitrary relay URLs from a +/// kind:10040 event; an external hint is reported as partial unless the +/// selected assertion has already been replicated into this relay. +#[derive(Debug)] +pub struct LocalProvider<'a> { + db: &'a Db, + community: CommunityId, + channel_id: Uuid, + requester_pubkey: Vec, + relay_url: &'a str, +} + +impl<'a> LocalProvider<'a> { + fn new( + db: &'a Db, + community: CommunityId, + channel_id: Uuid, + requester_pubkey: Vec, + relay_url: &'a str, + ) -> Self { + Self { + db, + community, + channel_id, + requester_pubkey, + relay_url, + } + } + + async fn resolve(&self, request: &Value) -> LocalTrustResult { + let request = match serde_json::from_value::(request.clone()) { + Ok(request) => request, + Err(_) => return self.invalid_request("invalid local reputation request"), + }; + if request.operation != "trust_network" { + return LocalTrustResult { + response: api_error( + StatusCode::NOT_IMPLEMENTED, + "the relay-local provider exposes signed trust evidence but does not compute reputation scores", + ), + resolution: ResolutionState::Unavailable, + diagnostics: vec![SourceDiagnostic { + source_id: "relay-local".to_string(), + resolution: ResolutionState::Unavailable, + detail: Some("operation is not implemented by this provider".to_string()), + }], + }; + } + let arguments = match serde_json::from_value::(request.arguments) { + Ok(arguments) => arguments, + Err(_) => return self.invalid_request("invalid trust query arguments"), + }; + if let Err(message) = validate_trust_arguments(&arguments) { + return self.invalid_request(&message); + } + match tokio::time::timeout(LOCAL_QUERY_DEADLINE, self.trust_network(arguments)).await { + Ok(Ok(result)) => result, + Ok(Err(detail)) => LocalTrustResult { + response: api_error( + StatusCode::SERVICE_UNAVAILABLE, + "relay-local reputation evidence is unavailable", + ), + resolution: ResolutionState::Unavailable, + diagnostics: vec![SourceDiagnostic { + source_id: "relay-local".to_string(), + resolution: ResolutionState::Unavailable, + detail: Some(detail), + }], + }, + Err(_) => LocalTrustResult { + response: api_error( + StatusCode::GATEWAY_TIMEOUT, + "relay-local reputation query exceeded its deadline", + ), + resolution: ResolutionState::Unavailable, + diagnostics: vec![SourceDiagnostic { + source_id: "relay-local".to_string(), + resolution: ResolutionState::Unavailable, + detail: Some("provider deadline exceeded".to_string()), + }], + }, + } + } + + fn invalid_request(&self, message: &str) -> LocalTrustResult { + LocalTrustResult { + response: api_error(StatusCode::BAD_REQUEST, message), + resolution: ResolutionState::Unavailable, + diagnostics: vec![SourceDiagnostic { + source_id: "relay-local".to_string(), + resolution: ResolutionState::Unavailable, + detail: Some("request contract validation failed".to_string()), + }], + } + } + + async fn trust_network( + &self, + arguments: TrustQueryArguments, + ) -> Result { + let limit = usize::from(arguments.limit.unwrap_or(DEFAULT_CLAIM_LIMIT as u16)) + .clamp(1, MAX_CLAIM_LIMIT); + let since = parse_timestamp(arguments.since, "since")?; + let until = parse_timestamp(arguments.until, "until")?; + if since.zip(until).is_some_and(|(since, until)| since > until) { + return Err("since must not be later than until".to_string()); + } + let cursor = arguments.cursor.as_deref().map(parse_cursor).transpose()?; + + let mut vouch_query = EventQuery::for_community(self.community); + vouch_query.channel_id = Some(self.channel_id); + vouch_query.kinds = Some(vec![1985]); + vouch_query.since = since; + vouch_query.until = until; + vouch_query.limit = Some(LOCAL_EVENT_LIMIT + 1); + + let mut preference_query = EventQuery::for_community(self.community); + preference_query.global_only = true; + preference_query.kinds = Some(vec![10040]); + preference_query.pubkey = Some(self.requester_pubkey.clone()); + preference_query.limit = Some(1); + + let (mut vouch_events, preference_events) = tokio::try_join!( + self.db.query_events(&vouch_query), + self.db.query_events(&preference_query) + ) + .map_err(|_| "relay event-store read failed".to_string())?; + + let preference = preference_events.first(); + let (selected_sources, truncated_sources) = preference + .map(|event| selected_sources(&event.event)) + .unwrap_or_default(); + let selected_authors = selected_sources + .iter() + .filter_map(|source| hex::decode(&source.pubkey).ok()) + .filter(|pubkey| pubkey.len() == 32) + .collect::>() + .into_iter() + .collect::>(); + + let vouch_window_truncated = vouch_events.len() > LOCAL_EVENT_LIMIT as usize; + vouch_events.truncate(LOCAL_EVENT_LIMIT as usize); + + let mut assertion_events = if selected_authors.is_empty() { + Vec::new() + } else { + let mut assertion_query = EventQuery::for_community(self.community); + assertion_query.global_only = true; + assertion_query.kinds = Some(vec![30382]); + assertion_query.authors = Some(selected_authors); + assertion_query.since = since; + assertion_query.until = until; + assertion_query.limit = Some(LOCAL_EVENT_LIMIT + 1); + self.db + .query_events(&assertion_query) + .await + .map_err(|_| "trusted-assertion read failed".to_string())? + }; + + let assertion_window_truncated = assertion_events.len() > LOCAL_EVENT_LIMIT as usize; + assertion_events.truncate(LOCAL_EVENT_LIMIT as usize); + let event_window_truncated = vouch_window_truncated || assertion_window_truncated; + + let mut claims = normalize_vouch_claims(&vouch_events, self.community, self.channel_id); + let (assertion_claims, matched_sources) = + normalize_assertion_claims(&assertion_events, &selected_sources, self.community); + claims.extend(assertion_claims); + claims.sort_by(|left, right| { + right + .created_at + .cmp(&left.created_at) + .then_with(|| left.event_id.cmp(&right.event_id)) + }); + if let Some((cursor_time, cursor_id)) = cursor { + claims.retain(|claim| { + claim.created_at < cursor_time + || (claim.created_at == cursor_time && claim.event_id > cursor_id) + }); + } + let has_next_page = claims.len() > limit; + claims.truncate(limit); + let next_cursor = has_next_page + .then(|| { + claims + .last() + .map(|claim| format_cursor(claim.created_at, &claim.event_id)) + }) + .flatten(); + + let encrypted_preferences = + preference.is_some_and(|event| !event.event.content.trim().is_empty()); + let unresolved_external = selected_sources.iter().any(|source| { + source + .relay + .as_deref() + .is_some_and(|relay| !same_relay(relay, self.relay_url)) + && !matched_sources.contains(&(source.pubkey.clone(), source.result_tag.clone())) + }); + let partial = truncated_sources + || encrypted_preferences + || unresolved_external + || event_window_truncated; + let resolution = if partial { + ResolutionState::Partial + } else { + ResolutionState::Complete + }; + let mut diagnostics = vec![SourceDiagnostic { + source_id: "relay-local".to_string(), + resolution: ResolutionState::Complete, + detail: None, + }]; + if encrypted_preferences { + diagnostics.push(SourceDiagnostic { + source_id: "nip85-private-sources".to_string(), + resolution: ResolutionState::Partial, + detail: Some( + "encrypted kind 10040 source selections cannot be decrypted by the relay" + .to_string(), + ), + }); + } + if truncated_sources { + diagnostics.push(SourceDiagnostic { + source_id: "nip85-public-sources".to_string(), + resolution: ResolutionState::Partial, + detail: Some(format!( + "source selection exceeds the bounded limit of {LOCAL_SOURCE_LIMIT}" + )), + }); + } + if unresolved_external { + diagnostics.push(SourceDiagnostic { + source_id: "nip85-external-relays".to_string(), + resolution: ResolutionState::Partial, + detail: Some( + "one or more selected assertions are not replicated locally; external relays were not contacted" + .to_string(), + ), + }); + } + if event_window_truncated { + diagnostics.push(SourceDiagnostic { + source_id: "relay-local-window".to_string(), + resolution: ResolutionState::Partial, + detail: Some(format!( + "matching event history exceeds the bounded {LOCAL_EVENT_LIMIT}-event provider window" + )), + }); + } + + let people = people_from_claims(&claims); + let vouches = vouches_from_claims(&claims); + let values = claims + .into_iter() + .map(|claim| claim.value) + .collect::>(); + let response = bounded_local_response(json!({ + "ok": true, + "channelId": self.channel_id, + "cg": format!("urn:buzz:relay-reputation:{}:{}", self.community, self.channel_id), + "operation": "trust_network", + "result": { + "completeness": if partial { "partial" } else { "complete" }, + "people": people, + "vouches": vouches, + "claims": values, + "nextCursor": next_cursor, + } + }))?; + Ok(LocalTrustResult { + response, + resolution, + diagnostics, + }) + } +} + +#[async_trait] +impl ReputationProvider for LocalProvider<'_> { + async fn attestations(&self, request: &Value) -> ReputationBatch { + let result = self.resolve(request).await; + ReputationBatch::local(result.resolution, result.response, result.diagnostics) + } +} + +fn parse_timestamp(raw: Option, field: &str) -> Result>, String> { + raw.map(|timestamp| { + Utc.timestamp_opt(timestamp, 0) + .single() + .ok_or_else(|| format!("{field} is outside the supported Unix timestamp range")) + }) + .transpose() +} + +fn validate_trust_arguments(arguments: &TrustQueryArguments) -> Result<(), String> { + let since = parse_timestamp(arguments.since, "since")?; + let until = parse_timestamp(arguments.until, "until")?; + if since.zip(until).is_some_and(|(since, until)| since > until) { + return Err("since must not be later than until".to_string()); + } + if let Some(cursor) = arguments.cursor.as_deref() { + parse_cursor(cursor)?; + } + Ok(()) +} + +fn parse_cursor(raw: &str) -> Result<(u64, String), String> { + let (timestamp, event_id) = raw + .split_once(':') + .ok_or_else(|| "cursor must be :".to_string())?; + let timestamp = timestamp + .parse::() + .map_err(|_| "cursor timestamp is invalid".to_string())?; + if event_id.len() != 64 || !event_id.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("cursor event id is invalid".to_string()); + } + Ok((timestamp, event_id.to_ascii_lowercase())) +} + +fn format_cursor(timestamp: u64, event_id: &str) -> String { + format!("{timestamp}:{event_id}") +} + +fn selected_sources(event: &nostr::Event) -> (Vec, bool) { + let mut sources = Vec::new(); + let mut seen = HashSet::new(); + let mut truncated = false; + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.len() < 2 + || !parts[0].starts_with("30382:") + || parts[1].len() != 64 + || !parts[1].bytes().all(|byte| byte.is_ascii_hexdigit()) + { + continue; + } + let result_tag = parts[0]["30382:".len()..].to_string(); + if result_tag.is_empty() { + continue; + } + let source = SelectedSource { + result_tag, + pubkey: parts[1].to_ascii_lowercase(), + relay: parts + .get(2) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + }; + if !seen.insert(source.clone()) { + continue; + } + if sources.len() == LOCAL_SOURCE_LIMIT { + truncated = true; + continue; + } + sources.push(source); + } + (sources, truncated) +} + +fn same_relay(left: &str, right: &str) -> bool { + fn key(raw: &str) -> Option<(String, String, Option, String)> { + let url = url::Url::parse(raw).ok()?; + let transport = match url.scheme() { + "ws" | "http" => "plain", + "wss" | "https" => "tls", + _ => return None, + }; + Some(( + transport.to_string(), + url.host_str()?.to_ascii_lowercase(), + url.port_or_known_default(), + url.path().trim_end_matches('/').to_string(), + )) + } + key(left) + .zip(key(right)) + .is_some_and(|(left, right)| left == right) +} + +fn tag_values(event: &nostr::Event, name: &str) -> Vec> { + event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some(name)).then(|| parts.to_vec()) + }) + .collect() +} + +fn first_tag_value(event: &nostr::Event, name: &str) -> Option { + tag_values(event, name) + .into_iter() + .find_map(|parts| parts.get(1).cloned()) +} + +fn label_value(event: &nostr::Event) -> Option { + event.tags.iter().find_map(|tag| { + let parts = tag.as_slice(); + (parts.len() >= 3 && parts[0] == "l" && parts[2] == "buzz.wot").then(|| parts[1].clone()) + }) +} + +fn source_document(stored: &StoredEvent) -> Value { + json!({ + "eventId": stored.event.id.to_hex(), + "kind": stored.event.kind.as_u16(), + "digest": stored.event.id.to_hex(), + "author": stored.event.pubkey.to_hex(), + "signature": stored.event.sig.to_string(), + "verified": stored.is_verified(), + }) +} + +fn bounded_local_response(value: Value) -> Result { + let bytes = serde_json::to_vec(&value) + .map_err(|_| "relay-local reputation response could not be serialized".to_string())?; + // Reserve headroom for provider metadata that is added by into_http_result. + let maximum = dkg_query::MAX_RESPONSE_BYTES.saturating_sub(16 * 1024); + if bytes.len() > maximum { + return Err(format!( + "relay-local reputation response exceeds the bounded {maximum}-byte result limit" + )); + } + Ok((StatusCode::OK, Json(value))) +} + +fn normalize_vouch_claims( + events: &[StoredEvent], + community: CommunityId, + channel_id: Uuid, +) -> Vec { + let mut base = HashMap::::new(); + for stored in events { + if label_value(&stored.event).as_deref() != Some("vouch") { + continue; + } + if let Some(subject) = first_tag_value(&stored.event, "p") { + base.insert( + stored.event.id.to_hex(), + (stored, subject.to_ascii_lowercase()), + ); + } + } + + let mut lifecycle = HashMap::)>::new(); + for stored in events { + let Some((status, action)) = (match label_value(&stored.event).as_deref() { + Some("revoke") => Some(("revoked", "revoke")), + Some("supersede") => Some(("superseded", "supersede")), + _ => None, + }) else { + continue; + }; + let Some(subject) = first_tag_value(&stored.event, "p") else { + continue; + }; + let target = stored.event.tags.iter().find_map(|tag| { + let parts = tag.as_slice(); + (parts.len() >= 4 && parts[0] == "e" && parts[3] == "target") + .then(|| parts[1].to_ascii_lowercase()) + }); + let Some(target) = target else { continue }; + let Some((target_event, target_subject)) = base.get(&target) else { + continue; + }; + if target_event.event.pubkey != stored.event.pubkey + || target_subject != &subject.to_ascii_lowercase() + { + continue; + } + let replacement = (action == "supersede") + .then(|| { + stored.event.tags.iter().find_map(|tag| { + let parts = tag.as_slice(); + (parts.len() >= 4 && parts[0] == "e" && parts[3] == "replacement") + .then(|| parts[1].to_ascii_lowercase()) + }) + }) + .flatten(); + if action == "supersede" + && !replacement.as_ref().is_some_and(|replacement| { + base.get(replacement) + .is_some_and(|(replacement_event, replacement_subject)| { + replacement_event.event.pubkey == stored.event.pubkey + && replacement_subject == &subject.to_ascii_lowercase() + }) + }) + { + continue; + } + lifecycle + .entry(target) + .or_insert((stored, status, replacement)); + } + + let now = Utc::now().timestamp().max(0) as u64; + base.into_iter() + .map(|(event_id, (stored, subject))| { + let created_at = stored.event.created_at.as_secs(); + let expiration = first_tag_value(&stored.event, "expiration") + .and_then(|value| value.parse::().ok()); + let lifecycle_event = lifecycle.get(&event_id); + let status = lifecycle_event + .map(|(_, status, _)| *status) + .or_else(|| expiration.filter(|expires| *expires <= now).map(|_| "expired")) + .unwrap_or("active"); + let issuer = stored.event.pubkey.to_hex(); + let evidence = stored + .event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + match parts.first().map(String::as_str) { + Some("r") => parts.get(1).map(|value| { + json!({"type": "uri", "value": value}) + }), + Some("e") if parts.get(3).map(String::as_str) == Some("evidence") => { + parts.get(1).map(|value| { + json!({"type": "nostr_event", "value": format!("urn:nostr:event:{value}")}) + }) + } + _ => None, + } + }) + .collect::>(); + let lifecycle_value = lifecycle_event.map(|(event, _, replacement)| { + json!({ + "eventId": event.event.id.to_hex(), + "source": source_document(event), + "replacementClaim": replacement, + }) + }); + let value = json!({ + "schemaVersion": "buzz-trust-claim@1", + "claimId": event_id, + "subject": subject, + "issuer": issuer, + "claimType": "vouch", + "claimLayer": "observation", + "scope": { + "community": community.to_string(), + "channel": channel_id, + "visibility": "channel", + }, + "source": source_document(stored), + "createdAt": created_at, + "expiresAt": expiration, + "status": status, + "derivedFrom": evidence, + "lifecycle": lifecycle_value, + "note": stored.event.content, + }); + NormalizedClaim { + created_at, + event_id: stored.event.id.to_hex(), + issuer: stored.event.pubkey.to_hex(), + subject, + claim_type: "vouch", + value, + } + }) + .collect() +} + +fn normalize_assertion_claims( + events: &[StoredEvent], + selected: &[SelectedSource], + community: CommunityId, +) -> (Vec, HashSet<(String, String)>) { + let selection = selected + .iter() + .map(|source| ((source.pubkey.clone(), source.result_tag.clone()), source)) + .collect::>(); + let mut matched = HashSet::new(); + let mut claims = Vec::new(); + for stored in events { + let issuer = stored.event.pubkey.to_hex(); + let Some(subject) = first_tag_value(&stored.event, "d") else { + continue; + }; + let mut assertions = BTreeMap::>::new(); + for tag in stored.event.tags.iter() { + let parts = tag.as_slice(); + let Some(name) = parts.first() else { continue }; + if !selection.contains_key(&(issuer.clone(), name.clone())) { + continue; + } + let values = parts.iter().skip(1).cloned().collect::>(); + if values.is_empty() { + continue; + } + matched.insert((issuer.clone(), name.clone())); + assertions.insert(name.clone(), values); + } + if assertions.is_empty() { + continue; + } + let created_at = stored.event.created_at.as_secs(); + let expiration = first_tag_value(&stored.event, "expiration") + .and_then(|value| value.parse::().ok()); + let status = if expiration.is_some_and(|expires| expires <= Utc::now().timestamp() as u64) { + "expired" + } else { + "active" + }; + let value = json!({ + "schemaVersion": "buzz-trust-claim@1", + "claimId": stored.event.id.to_hex(), + "subject": subject, + "issuer": issuer, + "claimType": "nip85_user_assertion", + "claimLayer": "derived_analysis", + "scope": { + "community": community.to_string(), + "visibility": "community", + }, + "source": source_document(stored), + "createdAt": created_at, + "expiresAt": expiration, + "status": status, + "derivedFrom": [], + "assertions": assertions, + }); + claims.push(NormalizedClaim { + created_at, + event_id: stored.event.id.to_hex(), + issuer: stored.event.pubkey.to_hex(), + subject: subject.to_ascii_lowercase(), + claim_type: "nip85_user_assertion", + value, + }); + } + (claims, matched) +} + +fn people_from_claims(claims: &[NormalizedClaim]) -> Vec { + #[derive(Default)] + struct Person { + latest: u64, + received: usize, + given: usize, + } + let mut people = BTreeMap::::new(); + for claim in claims { + people.entry(claim.subject.clone()).or_default().latest = people + .get(&claim.subject) + .map_or(claim.created_at, |person| { + person.latest.max(claim.created_at) + }); + if claim.claim_type == "vouch" && claim.value["status"] == "active" { + people.entry(claim.subject.clone()).or_default().received += 1; + people.entry(claim.issuer.clone()).or_default().given += 1; + people.entry(claim.issuer.clone()).or_default().latest = people + .get(&claim.issuer) + .map_or(claim.created_at, |person| { + person.latest.max(claim.created_at) + }); + } + } + people + .into_iter() + .map(|(pubkey, person)| { + json!({ + "pubkey": pubkey, + "contributions": 0, + "latest": person.latest, + "vouchesReceived": person.received, + "vouchesGiven": person.given, + "layer": "SWM", + }) + }) + .collect() +} + +fn vouches_from_claims(claims: &[NormalizedClaim]) -> Vec { + claims + .iter() + .filter(|claim| claim.claim_type == "vouch") + .map(|claim| { + let lifecycle = claim + .value + .get("lifecycle") + .filter(|value| !value.is_null()); + let evidence = claim.value["derivedFrom"] + .as_array() + .into_iter() + .flatten() + .filter_map(|item| item.get("value").cloned()) + .collect::>(); + json!({ + "uri": format!("urn:buzz-dkg:vouch:{}", claim.event_id), + "issuer": claim.issuer, + "subject": claim.subject, + "note": claim.value["note"], + "status": claim.value["status"], + "at": claim.created_at, + "sourceEvent": format!("urn:nostr:event:{}", claim.event_id), + "evidence": evidence, + "lifecycleEvent": lifecycle.and_then(|value| value.get("eventId")), + "replacementVouch": lifecycle.and_then(|value| value.get("replacementClaim")), + "layer": "SWM", + }) + }) + .collect() +} + /// Adapter for the existing bounded OriginTrail DKG trust operations. #[derive(Debug)] pub struct DkgProvider<'a> { @@ -304,18 +1084,44 @@ fn validate_gateway_success( pub enum ConfiguredReputationProvider<'a> { /// Deterministic default when reputation is not configured. Null(NullProvider), + /// Signed NIP-32/NIP-85 evidence resolved from this relay. + Local(LocalProvider<'a>), /// Existing OriginTrail DKG trust-query adapter. Dkg(DkgProvider<'a>), } impl<'a> ConfiguredReputationProvider<'a> { - /// Select the configured DKG adapter or the null provider. - pub fn from_dkg_config(config: Option<&'a DkgQueryConfig>) -> Self { - match config.filter(|config| config.trust_enabled) { - Some(config) => Self::Dkg(DkgProvider::new(config)), - None => Self::Null(NullProvider), + /// Select the configured provider without allowing request input to choose + /// a backend or tenant scope. + pub fn from_config( + kind: ReputationProviderKind, + dkg_config: Option<&'a DkgQueryConfig>, + db: &'a Db, + community: CommunityId, + channel_id: Uuid, + requester_pubkey: Vec, + relay_url: &'a str, + ) -> Self { + match kind { + ReputationProviderKind::Local => Self::Local(LocalProvider::new( + db, + community, + channel_id, + requester_pubkey, + relay_url, + )), + ReputationProviderKind::Dkg => match dkg_config.filter(|config| config.trust_enabled) { + Some(config) => Self::Dkg(DkgProvider::new(config)), + None => Self::Null(NullProvider), + }, + ReputationProviderKind::Disabled => Self::Null(NullProvider), } } + + #[cfg(test)] + fn disabled() -> Self { + Self::Null(NullProvider) + } } #[async_trait] @@ -323,6 +1129,7 @@ impl ReputationProvider for ConfiguredReputationProvider<'_> { async fn attestations(&self, request: &Value) -> ReputationBatch { match self { Self::Null(provider) => provider.attestations(request).await, + Self::Local(provider) => provider.attestations(request).await, Self::Dkg(provider) => provider.attestations(request).await, } } @@ -345,10 +1152,102 @@ fn resolution_from_gateway(value: &Value) -> ResolutionState { #[cfg(test)] mod tests { use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + fn stored_event( + keys: &Keys, + kind: u16, + content: &str, + tags: Vec, + channel_id: Option, + ) -> StoredEvent { + let event = EventBuilder::new(Kind::Custom(kind), content) + .tags(tags) + .sign_with_keys(keys) + .expect("sign fixture event"); + StoredEvent::with_received_at(event, Utc::now(), channel_id, true) + } + + fn tag(parts: &[&str]) -> Tag { + Tag::parse(parts.iter().copied()).expect("valid fixture tag") + } + + #[derive(Debug)] + struct FixtureProvider { + state: ResolutionState, + } + + #[async_trait] + impl ReputationProvider for FixtureProvider { + async fn attestations(&self, _request: &Value) -> ReputationBatch { + ReputationBatch { + resolution: self.state, + provider_id: "fixture", + provider_version: "1", + as_of: Utc::now(), + source_diagnostics: vec![SourceDiagnostic { + source_id: "fixture-source".to_string(), + resolution: self.state, + detail: None, + }], + response: Some(if self.state == ResolutionState::Unavailable { + api_error(StatusCode::SERVICE_UNAVAILABLE, "fixture unavailable") + } else { + ( + StatusCode::OK, + Json(json!({ + "ok": true, + "channelId": Uuid::nil(), + "cg": "fixture", + "operation": "trust_network", + "result": { + "completeness": if self.state == ResolutionState::Partial { + "partial" + } else { + "complete" + }, + "claims": [], + } + })), + ) + }), + } + } + } + + async fn assert_provider_conformance( + provider: &dyn ReputationProvider, + expected: ResolutionState, + ) { + let batch = provider.attestations(&json!({})).await; + assert_eq!(batch.resolution, expected); + assert!(!batch.provider_id.is_empty()); + assert!(!batch.provider_version.is_empty()); + assert!(batch.as_of <= Utc::now()); + if expected == ResolutionState::Disabled { + assert!(batch.source_diagnostics.is_empty()); + } else { + assert!(batch + .source_diagnostics + .iter() + .all(|diagnostic| !diagnostic.source_id.is_empty())); + } + let response = batch.into_http_result(); + match expected { + ResolutionState::Complete | ResolutionState::Partial => { + let (_, Json(value)) = response.expect("resolved provider succeeds"); + assert_eq!(value["resolution"], json!(expected)); + } + ResolutionState::Disabled | ResolutionState::Unavailable => { + let (_, Json(value)) = response.expect_err("unresolved provider fails"); + assert_eq!(value["resolution"], json!(expected)); + } + } + } #[tokio::test] async fn null_provider_is_disabled_not_empty_complete() { - let provider = ConfiguredReputationProvider::from_dkg_config(None); + let provider = ConfiguredReputationProvider::disabled(); let batch = provider.attestations(&serde_json::json!({})).await; assert_eq!(batch.resolution, ResolutionState::Disabled); assert_eq!(batch.provider_id, "null"); @@ -362,6 +1261,128 @@ mod tests { assert_eq!(error.1 .0["providerId"], "null"); } + #[tokio::test] + async fn providers_share_resolution_and_diagnostic_conformance() { + assert_provider_conformance(&NullProvider, ResolutionState::Disabled).await; + for state in [ + ResolutionState::Complete, + ResolutionState::Partial, + ResolutionState::Unavailable, + ] { + assert_provider_conformance(&FixtureProvider { state }, state).await; + } + } + + #[test] + fn nip85_selection_is_exact_per_result_tag_and_bounded() { + let provider = Keys::generate().public_key().to_hex(); + let event = stored_event( + &Keys::generate(), + 10040, + "", + vec![ + tag(&["30382:rank", &provider, "wss://relay.example"]), + tag(&["30382:rank", &provider, "wss://relay.example"]), + tag(&["30382:followers", &provider]), + tag(&["30383:rank", &provider]), + ], + None, + ); + let (sources, truncated) = selected_sources(&event.event); + assert!(!truncated); + assert_eq!(sources.len(), 2); + assert!(sources.iter().any(|source| source.result_tag == "rank")); + assert!(sources + .iter() + .any(|source| source.result_tag == "followers")); + } + + #[test] + fn local_claims_preserve_signed_evidence_and_lifecycle() { + let channel = Uuid::new_v4(); + let community = CommunityId::from_uuid(Uuid::new_v4()); + let issuer = Keys::generate(); + let subject = Keys::generate().public_key().to_hex(); + let vouch = stored_event( + &issuer, + 1985, + "Built the release pipeline", + vec![ + tag(&["h", &channel.to_string()]), + tag(&["L", "buzz.wot"]), + tag(&["l", "vouch", "buzz.wot"]), + tag(&["p", &subject]), + tag(&["r", "https://example.test/evidence/1"]), + ], + Some(channel), + ); + let revoke = stored_event( + &issuer, + 1985, + "Evidence was withdrawn", + vec![ + tag(&["h", &channel.to_string()]), + tag(&["L", "buzz.wot"]), + tag(&["l", "revoke", "buzz.wot"]), + tag(&["p", &subject]), + tag(&["e", &vouch.event.id.to_hex(), "", "target"]), + ], + Some(channel), + ); + let claims = normalize_vouch_claims(&[revoke, vouch], community, channel); + assert_eq!(claims.len(), 1); + assert_eq!(claims[0].value["status"], "revoked"); + assert_eq!( + claims[0].value["derivedFrom"][0]["value"], + "https://example.test/evidence/1" + ); + assert_eq!(claims[0].value["source"]["verified"], true); + assert!(claims[0].value["source"]["signature"].is_string()); + assert!(claims[0].value["source"].get("event").is_none()); + assert!(claims[0].value["lifecycle"]["source"]["eventId"].is_string()); + } + + #[test] + fn local_provider_enforces_the_public_response_byte_bound() { + let oversized = json!({"claims": ["x".repeat(dkg_query::MAX_RESPONSE_BYTES)]}); + assert!(bounded_local_response(oversized).is_err()); + assert!(bounded_local_response(json!({"claims": []})).is_ok()); + } + + #[test] + fn trusted_assertions_include_only_viewer_selected_results() { + let community = CommunityId::from_uuid(Uuid::new_v4()); + let provider = Keys::generate(); + let subject = Keys::generate().public_key().to_hex(); + let assertion = stored_event( + &provider, + 30382, + "", + vec![ + tag(&["d", &subject]), + tag(&["rank", "89"]), + tag(&["followers", "123"]), + ], + None, + ); + let selected = vec![SelectedSource { + result_tag: "rank".to_string(), + pubkey: provider.public_key().to_hex(), + relay: None, + }]; + let (claims, matched) = normalize_assertion_claims(&[assertion], &selected, community); + assert_eq!(claims.len(), 1); + assert_eq!(claims[0].value["assertions"]["rank"], json!(["89"])); + assert!(claims[0].value["assertions"].get("followers").is_none()); + assert!(matched.contains(&(provider.public_key().to_hex(), "rank".to_string()))); + } + + #[test] + fn relay_hint_comparison_normalizes_http_and_websocket_schemes() { + assert!(same_relay("wss://relay.example/", "https://RELAY.example")); + assert!(!same_relay("wss://relay.example", "wss://other.example")); + } + #[test] fn gateway_completeness_maps_to_explicit_resolution_states() { assert_eq!( diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index f07dd89cb42..2b7b9579e86 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -72,6 +72,21 @@ pub struct DkgQueryConfig { pub trust_enabled: bool, } +/// Reputation evidence backend exposed by the authenticated relay query API. +/// +/// `Disabled` is the safe default for a plain Buzz relay. Existing deployments +/// that already enable the DKG trust profile retain that provider unless they +/// explicitly choose the relay-local implementation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReputationProviderKind { + /// Do not expose reputation evidence queries. + Disabled, + /// Resolve signed NIP-32/NIP-85 evidence from this relay's event store. + Local, + /// Forward reputation operations to the configured OriginTrail DKG gateway. + Dkg, +} + impl std::fmt::Debug for DkgQueryConfig { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter @@ -312,9 +327,13 @@ pub struct Config { /// Hard timeout for one gateway delivery request. pub push_gateway_timeout: Duration, - /// Internal DKG read gateway. Absent means `/api/dkg/query` is not routed. + /// Internal DKG read gateway. When absent, `/api/dkg/query` is routed only + /// if the relay-local reputation provider is enabled. pub dkg_query: Option, + /// Backend used for evidence-backed trust and reputation reads. + pub reputation_provider: ReputationProviderKind, + /// Optional relay-hosted policy shown on join surfaces. Disabled when no /// documents or age attestation are configured. pub join_policy: Option, @@ -533,6 +552,37 @@ fn parse_dkg_query_config( })) } +fn parse_reputation_provider( + raw: Option, + dkg_query: Option<&DkgQueryConfig>, +) -> Result { + let configured = raw + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_ascii_lowercase); + let provider = match configured.as_deref() { + None if dkg_query.is_some_and(|config| config.trust_enabled) => ReputationProviderKind::Dkg, + None | Some("disabled" | "off" | "none") => ReputationProviderKind::Disabled, + Some("local") => ReputationProviderKind::Local, + Some("dkg") => ReputationProviderKind::Dkg, + Some(_) => { + return Err(ConfigError::InvalidValue( + "BUZZ_REPUTATION_PROVIDER must be disabled, local, or dkg".to_string(), + )); + } + }; + if provider == ReputationProviderKind::Dkg + && !dkg_query.is_some_and(|config| config.trust_enabled) + { + return Err(ConfigError::InvalidValue( + "BUZZ_REPUTATION_PROVIDER=dkg requires a DKG query gateway with BUZZ_DKG_TRUST_ENABLED=true" + .to_string(), + )); + } + Ok(provider) +} + fn parse_bool(name: &str, default: bool) -> Result { match std::env::var(name) { Err(std::env::VarError::NotPresent) => Ok(default), @@ -1037,6 +1087,10 @@ impl Config { parse_bool("BUZZ_DKG_MEMORY_ENABLED", false)?, parse_bool("BUZZ_DKG_TRUST_ENABLED", false)?, )?; + let reputation_provider = parse_reputation_provider( + optional_unicode_env("BUZZ_REPUTATION_PROVIDER")?, + dkg_query.as_ref(), + )?; const MAX_POLICY_MARKDOWN_BYTES: usize = 256 * 1024; let read_policy_markdown = |name: &str| -> Result, ConfigError> { @@ -1188,6 +1242,7 @@ impl Config { push_gateway_delivery_url, push_gateway_timeout, dkg_query, + reputation_provider, join_policy, admin, web_dir, @@ -1810,6 +1865,40 @@ mod tests { .is_err()); } + #[test] + fn reputation_provider_defaults_safely_and_preserves_existing_dkg_trust() { + assert_eq!( + parse_reputation_provider(None, None).expect("plain relay default"), + ReputationProviderKind::Disabled + ); + assert_eq!( + parse_reputation_provider(Some("local".to_string()), None) + .expect("local provider needs no DKG gateway"), + ReputationProviderKind::Local + ); + assert!(parse_reputation_provider(Some("dkg".to_string()), None).is_err()); + assert!(parse_reputation_provider(Some("remote".to_string()), None).is_err()); + + let dkg = parse_dkg_query_config( + Some("http://127.0.0.1:9296/v1/query".to_string()), + Some("0123456789abcdef0123456789abcdef".to_string()), + None, + true, + true, + ) + .expect("valid DKG gateway") + .expect("configured DKG gateway"); + assert_eq!( + parse_reputation_provider(None, Some(&dkg)).expect("legacy DKG default"), + ReputationProviderKind::Dkg + ); + assert_eq!( + parse_reputation_provider(Some("local".to_string()), Some(&dkg)) + .expect("explicit local override"), + ReputationProviderKind::Local + ); + } + #[test] fn dkg_query_gateway_accepts_exact_internal_endpoint_and_bounds_timeout() { let default_timeout = parse_dkg_query_config( diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index bfe409f62cf..3b88cfd0891 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -44,6 +44,9 @@ pub struct RelayInfo { /// Versioned DKG memory profiles and fixed query operations exposed by this relay. #[serde(skip_serializing_if = "Option::is_none")] pub dkg_memory: Option, + /// Backend-neutral signed reputation-evidence provider, when configured. + #[serde(skip_serializing_if = "Option::is_none")] + pub reputation: Option, /// NIP-PL executor descriptor. Present only when push delivery is configured. #[serde(skip_serializing_if = "Option::is_none")] pub push: Option, @@ -168,6 +171,7 @@ impl RelayInfo { supported_nips, supported_extensions: Some(vec!["nip-er".to_string()]), dkg_memory: None, + reputation: None, push: None, software: "https://github.com/block/buzz".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), @@ -267,6 +271,38 @@ fn dkg_memory_descriptor(trust_enabled: bool) -> serde_json::Value { }) } +fn reputation_descriptor( + provider: crate::config::ReputationProviderKind, +) -> Option { + use crate::config::ReputationProviderKind; + + let (provider_id, operations) = match provider { + ReputationProviderKind::Disabled => return None, + ReputationProviderKind::Local => ("buzz-relay-local", vec!["trust_network"]), + ReputationProviderKind::Dkg => ( + "origintrail-dkg", + vec!["trust_network", "reputation_summary"], + ), + }; + let limits = match provider { + ReputationProviderKind::Local => serde_json::json!({ + "max_claims": 100, + "deadline_ms": 2000, + "max_nip85_sources": 32, + }), + ReputationProviderKind::Dkg => serde_json::json!({"bounded": true}), + ReputationProviderKind::Disabled => unreachable!("handled above"), + }; + Some(serde_json::json!({ + "contract": "buzz-reputation@1", + "provider": provider_id, + "claim_schema": "buzz-trust-claim@1", + "operations": operations, + "resolution_states": ["disabled", "complete", "partial", "unavailable"], + "limits": limits, + })) +} + /// Builds the served NIP-11 document for a request arriving on `raw_host`. /// /// Centralised so the content-negotiated root handler and the dedicated @@ -314,6 +350,12 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st .push("buzz-dkg-memory-v2".to_string()); } } + if let Some(reputation) = reputation_descriptor(state.config.reputation_provider) { + info.supported_extensions + .get_or_insert_default() + .push("buzz-reputation-v1".to_string()); + info.reputation = Some(reputation); + } info } @@ -406,6 +448,27 @@ mod tests { ); } + #[test] + fn reputation_descriptor_is_backend_neutral_and_local_is_bounded() { + use crate::config::ReputationProviderKind; + + assert!(reputation_descriptor(ReputationProviderKind::Disabled).is_none()); + let local = reputation_descriptor(ReputationProviderKind::Local) + .expect("local provider descriptor"); + assert_eq!(local["contract"], "buzz-reputation@1"); + assert_eq!(local["claim_schema"], "buzz-trust-claim@1"); + assert_eq!(local["operations"], serde_json::json!(["trust_network"])); + assert_eq!(local["limits"]["max_claims"], 100); + assert_eq!(local["limits"]["deadline_ms"], 2000); + + let dkg = + reputation_descriptor(ReputationProviderKind::Dkg).expect("DKG provider descriptor"); + assert_eq!( + dkg["operations"], + serde_json::json!(["trust_network", "reputation_summary"]) + ); + } + #[test] fn supported_nips_includes_nip23_and_nip33() { // Tests the production SUPPORTED_NIPS constant directly — no Config::from_env() diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index a15474e4b86..02788658515 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -50,13 +50,20 @@ pub fn build_router(state: Arc) -> Router { let git_policy_router = api::git::git_policy_router(state.clone()); - let dkg_query_router = state.config.dkg_query.as_ref().map(|config| { + let reputation_routed = + state.config.reputation_provider != crate::config::ReputationProviderKind::Disabled; + let dkg_query_router = (state.config.dkg_query.is_some() || reputation_routed).then(|| { let mut router = Router::new() .route("/api/dkg/query", post(api::dkg_query::query)) .layer(RequestBodyLimitLayer::new( api::dkg_query::MAX_REQUEST_BYTES, )); - if config.agent_memory_enabled { + if state + .config + .dkg_query + .as_ref() + .is_some_and(|config| config.agent_memory_enabled) + { router = router.merge( Router::new() .route("/api/dkg/memory", post(api::dkg_memory::propose)) diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index f6ab4fcab97..df9ea1edcc2 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -18,6 +18,8 @@ BUZZ_REQUIRE_RELAY_MEMBERSHIP=true BUZZ_ALLOW_NIP_OA_AUTH=true BUZZ_AUTO_MIGRATE=true BUZZ_GIT_CONFORMANCE_PROBE=true +# Optional signed trust-evidence reads without a DKG gateway. +# BUZZ_REPUTATION_PROVIDER=local RUST_LOG=buzz_relay=info,buzz_db=info,buzz_auth=info,buzz_pubsub=info,tower_http=info # Owner identity. Set to a 64-character hex Nostr pubkey. diff --git a/desktop/src/features/dkg-memory/api.ts b/desktop/src/features/dkg-memory/api.ts index 9846ddc81e6..0a806728b50 100644 --- a/desktop/src/features/dkg-memory/api.ts +++ b/desktop/src/features/dkg-memory/api.ts @@ -135,6 +135,38 @@ export interface TrustVouch { layer: "SWM" | "VM"; } +export interface TrustClaimSource { + eventId: string; + kind: number; + digest: string; + author: string; + signature: string; + verified: boolean; + event: unknown; +} + +/** Provider-neutral projection of a signed native Nostr trust event. */ +export interface TrustClaim { + schemaVersion: "buzz-trust-claim@1"; + claimId: string; + subject: string; + issuer: string; + claimType: "vouch" | "nip85_user_assertion"; + claimLayer: "observation" | "derived_analysis"; + scope: { + community: string; + channel?: string; + visibility: "channel" | "community"; + }; + source: TrustClaimSource; + createdAt: number; + expiresAt: number | null; + status: "active" | "expired" | "revoked" | "superseded"; + derivedFrom: Array<{ type: "uri" | "nostr_event"; value: string }>; + note?: string; + assertions?: Record; +} + export interface WorkEvidence { uri: string; kind: string; @@ -150,6 +182,8 @@ export interface TrustNetwork extends Partial { completeness: "complete" | "partial"; people: TrustPerson[]; vouches: TrustVouch[]; + claims?: TrustClaim[]; + nextCursor?: string | null; } export interface ReputationSummary extends Partial { @@ -505,11 +539,17 @@ export async function fetchDecisionTrace( /** Read the channel's evidence-backed trust network without computing a score. */ export async function fetchTrustNetwork( channelId: string, + page: { + limit?: number; + cursor?: string; + since?: number; + until?: number; + } = {}, ): Promise { return queryDkgProvider({ channelId, operation: "trust_network", - arguments: {}, + arguments: page, localPath: null, }); } diff --git a/desktop/src/features/dkg-memory/capabilities.ts b/desktop/src/features/dkg-memory/capabilities.ts index 030257b1014..0e4a5a58c1e 100644 --- a/desktop/src/features/dkg-memory/capabilities.ts +++ b/desktop/src/features/dkg-memory/capabilities.ts @@ -11,6 +11,7 @@ export type DkgMemoryCapabilities = { type RelayCapabilityDocument = { dkg_memory?: unknown; + reputation?: unknown; supported_extensions?: unknown; }; @@ -51,6 +52,22 @@ export function parseDkgMemoryCapabilities( semantic_query?: unknown; }) : null; + const reputationDescriptor = + capability.reputation && typeof capability.reputation === "object" + ? (capability.reputation as { + contract?: unknown; + claim_schema?: unknown; + operations?: unknown; + }) + : null; + const providerContract = + hasString( + capability.supported_extensions, + capabilityContract.provider.extension, + ) && + reputationDescriptor?.contract === capabilityContract.provider.contract && + reputationDescriptor?.claim_schema === + capabilityContract.provider.claim_schema; const descriptorSupportsV2 = supportsV2 && Array.isArray(descriptor?.schema_versions) && @@ -77,15 +94,28 @@ export function parseDkgMemoryCapabilities( hasString(semanticDescriptor?.forms, form), ); const trust = - memory && - hasString(descriptor?.profiles, capabilityContract.trust.profile) && - hasString(descriptor?.query_operations, capabilityContract.trust.operation); + (memory && + hasString(descriptor?.profiles, capabilityContract.trust.profile) && + hasString( + descriptor?.query_operations, + capabilityContract.trust.operation, + )) || + (providerContract && + hasString( + reputationDescriptor?.operations, + capabilityContract.trust.operation, + )); const reputation = trust && - hasString( + (hasString( descriptor?.query_operations, capabilityContract.reputation.operation, - ); + ) || + (providerContract && + hasString( + reputationDescriptor?.operations, + capabilityContract.reputation.operation, + ))); return { memory, semanticQuery, diff --git a/desktop/src/features/dkg-memory/messageStatus.test.mjs b/desktop/src/features/dkg-memory/messageStatus.test.mjs index 5608b803fc4..3350197685b 100644 --- a/desktop/src/features/dkg-memory/messageStatus.test.mjs +++ b/desktop/src/features/dkg-memory/messageStatus.test.mjs @@ -341,4 +341,47 @@ test("relay discovery mirrors the ACP memory capability contract", () => { }).reputation, false, ); + assert.deepEqual( + parseDkgMemoryCapabilities({ + supported_extensions: ["buzz-reputation-v1"], + reputation: { + contract: "buzz-reputation@1", + claim_schema: "buzz-trust-claim@1", + operations: ["trust_network"], + }, + }), + { + memory: false, + semanticQuery: false, + trust: true, + reputation: false, + }, + ); + assert.deepEqual( + parseDkgMemoryCapabilities({ + supported_extensions: ["buzz-reputation-v1"], + reputation: { + contract: "buzz-reputation@1", + claim_schema: "buzz-trust-claim@1", + operations: ["trust_network", "reputation_summary"], + }, + }), + { + memory: false, + semanticQuery: false, + trust: true, + reputation: true, + }, + ); + assert.equal( + parseDkgMemoryCapabilities({ + supported_extensions: ["buzz-reputation-v1"], + reputation: { + contract: "buzz-reputation@1", + claim_schema: "wrong-schema", + operations: ["trust_network"], + }, + }).trust, + false, + ); }); diff --git a/desktop/src/features/dkg-memory/provider.test.mjs b/desktop/src/features/dkg-memory/provider.test.mjs index 3ebe9f4f19a..e454f7ab8f5 100644 --- a/desktop/src/features/dkg-memory/provider.test.mjs +++ b/desktop/src/features/dkg-memory/provider.test.mjs @@ -655,6 +655,55 @@ test("a vouch signs and publishes human evidence before proposing its DKG projec assert.equal(signed.at(-1).kind, 27235); }); +test("a memory-only relay stores a signed vouch without queuing unsupported trust projection", async () => { + const issuer = "a".repeat(64); + const subject = "b".repeat(64); + const signed = []; + installTauri("https://relay.example/", (args) => { + const event = { + id: String(signed.length + 1).repeat(64), + sig: "c".repeat(128), + pubkey: issuer, + kind: args.kind, + created_at: 1_786_363_200 + signed.length, + tags: args.tags, + content: args.content, + }; + signed.push(event); + return event; + }); + const storage = new Map(); + globalThis.localStorage = { + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, String(value)), + removeItem: (key) => storage.delete(key), + }; + const published = []; + mock.method(relayClient, "publishEvent", async (event) => + published.push(event), + ); + const requests = []; + globalThis.fetch = async (url) => { + requests.push(String(url)); + return new Response( + JSON.stringify({ supported_extensions: ["buzz-dkg-memory-v1"] }), + ); + }; + + const result = await publishTrustVouch({ + channelId: CHANNEL_ID, + subjectPubkey: subject, + subjectName: "Alice", + note: "Reviewed the release.", + }); + + assert.equal(result.state, "stored"); + assert.equal(signed.length, 1, "no DKG proposal or NIP-98 event is signed"); + assert.deepEqual(published, [signed[0]]); + assert.deepEqual(requests, ["https://relay.example/"]); + assert.equal(storage.has("buzz-dkg-trust-projections.v1"), false); +}); + test("revoke publishes an append-only signed lifecycle event and exact DKG projection", async () => { const issuer = "a".repeat(64); const subject = "b".repeat(64); diff --git a/desktop/src/features/dkg-memory/provider.ts b/desktop/src/features/dkg-memory/provider.ts index e8c93c05da1..8915293d968 100644 --- a/desktop/src/features/dkg-memory/provider.ts +++ b/desktop/src/features/dkg-memory/provider.ts @@ -52,7 +52,12 @@ type DkgQueryArguments = { commitSha: string; componentName: string; }; - trust_network: Record; + trust_network: { + limit?: number; + cursor?: string; + since?: number; + until?: number; + }; reputation_summary: { pubkey: string }; subgraph_graph: { name: string }; subgraph_triples: { name: string }; diff --git a/desktop/src/features/dkg-memory/trustActions.ts b/desktop/src/features/dkg-memory/trustActions.ts index 3fb7501f3a4..31a04312cbf 100644 --- a/desktop/src/features/dkg-memory/trustActions.ts +++ b/desktop/src/features/dkg-memory/trustActions.ts @@ -1,7 +1,8 @@ import { relayClient } from "@/shared/api/relayClient"; -import { signRelayEvent } from "@/shared/api/tauri"; +import { getRelayHttpUrl, signRelayEvent } from "@/shared/api/tauri"; import type { RelayEvent } from "@/shared/api/types"; import type { WorkEvidence } from "./api"; +import { fetchDkgMemoryCapabilities } from "./capabilities"; import { postAuthenticatedDkgJson } from "./provider"; import { memoryProposalProgress, @@ -127,11 +128,32 @@ async function postTrustMemoryProposal( return { ...result, ...normalizeMemoryProposalResponse(result, status) }; } +async function relayProjectsTrustToDkg(): Promise { + try { + const relay = (await getRelayHttpUrl()).replace(/\/+$/, ""); + const capabilities = await fetchDkgMemoryCapabilities(relay); + return capabilities.memory && capabilities.trust; + } catch { + // Discovery failure is not proof that projection is disabled. Preserve + // the durable DKG path and let its authenticated request decide. + return true; + } +} + async function publishAndProjectTrustSource( channelId: string, source: RelayEvent, content: Record, ): Promise<{ eventId: string; state?: string }> { + if (!(await relayProjectsTrustToDkg())) { + await relayClient.publishEvent( + source, + "The signed trust event timed out before the relay confirmed it.", + "The relay could not publish the signed trust event.", + ); + clearProjection(source.id); + return { eventId: source.id, state: "stored" }; + } const proposal = await signRelayEvent({ kind: 40009, content: JSON.stringify(content), @@ -186,6 +208,7 @@ export async function retryPendingTrustProjections( const pending = readProjectionOutbox().filter( (entry) => entry.channelId === channelId, ); + const projectionEnabled = await relayProjectsTrustToDkg(); let completed = 0; for (const entry of pending) { try { @@ -198,6 +221,11 @@ export async function retryPendingTrustProjections( entry.sourcePublished = true; queueProjection(entry); } + if (!projectionEnabled) { + clearProjection(entry.sourceEventId); + completed += 1; + continue; + } const result = await postTrustMemoryProposal(entry.proposalBody); if (memoryProposalProgress(result.state) === "stored") { clearProjection(entry.sourceEventId); diff --git a/desktop/src/features/dkg-memory/ui/MemoryPanel.tsx b/desktop/src/features/dkg-memory/ui/MemoryPanel.tsx index 9373fd96cd4..80dd54b54b7 100644 --- a/desktop/src/features/dkg-memory/ui/MemoryPanel.tsx +++ b/desktop/src/features/dkg-memory/ui/MemoryPanel.tsx @@ -17,6 +17,7 @@ import { MemoryProvisioningGate, } from "./MemoryPanelStates"; import { resolveMemoryPanelState } from "./memoryPanelState"; +import { WebOfTrustPanel } from "./WebOfTrustPanel"; export function MemoryPanel({ channelId }: { channelId: string }) { const queryClient = useQueryClient(); @@ -26,7 +27,7 @@ export function MemoryPanel({ channelId }: { channelId: string }) { const memory = useChannelMemory( channelId, localCgOverride, - !cgQuery.isLoading, + !cgQuery.isLoading && capabilities.data?.memory !== false, ); const cg = memory.data?.cg ?? localCgOverride; const [enabling, setEnabling] = useState(false); @@ -43,6 +44,28 @@ export function MemoryPanel({ channelId }: { channelId: string }) { panelState.kind === "fallback", ); + if (capabilities.isLoading) { + return ( + + + + ); + } + + if (capabilities.data?.trust && !capabilities.data.memory) { + return ( + + + + ); + } + async function startMemory() { setEnabling(true); setEnableError(null); @@ -130,9 +153,13 @@ export function MemoryPanel({ channelId }: { channelId: string }) { function PanelShell({ action, children, + title = "Channel memory", + subtitle = "Powered by OriginTrail DKG", }: { action?: React.ReactNode; children: React.ReactNode; + title?: string; + subtitle?: string; }) { return (
@@ -141,10 +168,8 @@ function PanelShell({
-

Channel memory

-

- Powered by OriginTrail DKG -

+

{title}

+

{subtitle}

{action} diff --git a/shared/dkg-memory/capability-contract.json b/shared/dkg-memory/capability-contract.json index 7c8ffd73d5e..d15bb195aa1 100644 --- a/shared/dkg-memory/capability-contract.json +++ b/shared/dkg-memory/capability-contract.json @@ -18,5 +18,10 @@ "operation": "reputation_summary", "max_hops": 2, "methodology": "dkg-reputation-v1" + }, + "provider": { + "extension": "buzz-reputation-v1", + "contract": "buzz-reputation@1", + "claim_schema": "buzz-trust-claim@1" } }