Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# -----------------------------------------------------------------------------
Expand Down
18 changes: 14 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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:**

Expand Down
107 changes: 98 additions & 9 deletions crates/buzz-relay/src/api/dkg_query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
cursor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
since: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
until: Option<i64>,
}

#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct PubkeyArguments {
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -374,12 +416,6 @@ pub async fn query(
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<(StatusCode, Json<Value>), (StatusCode, Json<Value>)> {
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())
Expand Down Expand Up @@ -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"))?;
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading