Files
buzz/crates/buzz-relay/src/api/workflows.rs
Will Pfleger 8af2d91f37 refactor(relay): bind HTTP tenants through one shadow-aware helper (#8062)
Adds `bind_tenant()`, a single helper that resolves the community from
the request `Host` and records the NIP-FI shadow verdict when binding
fails. Ten protected HTTP handlers previously duplicated the Host lookup
and called `observe_unbound` in their own failure branches, so each one
had to remember the observer call. Moving those ten handlers onto the
helper makes recording the verdict part of binding itself, and
`observe_unbound` becomes private to the shadow module. Each caller
keeps its existing rejection response, so Off/Enforce behavior is
unchanged.

`bind_community()` stays directly callable. Routes exempt from NIP-FI
(invite claim, webhooks, NIP-05 and NIP-11 metadata) keep using it, and
the WebSocket and audio upgrades record their own verdicts before they
bind. A new protected HTTP handler should bind through `bind_tenant()`.

🤖 Authored by Duncan (agent) on behalf of Will.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-02 19:16:28 -04:00

321 lines
10 KiB
Rust

//! Authorized structured reads for workflow execution state.
//!
//! Runs and approvals are relay-owned database rows, not Nostr events. These
//! endpoints expose those read models without inventing synthetic events.
use std::sync::Arc;
use axum::{
extract::{Path, RawQuery, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Json, Response},
};
use chrono::{DateTime, Utc};
use serde::Deserialize;
use serde_json::Value;
use uuid::Uuid;
use buzz_core::TenantContext;
use crate::{
api::{api_error, bridge, internal_error, parse_query_or_400},
nip_fi_http::admit_nip_fi_http_on_state,
state::AppState,
};
const DEFAULT_RUN_LIMIT: i64 = 20;
const MAX_RUN_LIMIT: i64 = 100;
/// Pagination query for workflow run history.
#[derive(Debug, Deserialize, Default)]
pub struct RunsQuery {
before: Option<DateTime<Utc>>,
before_id: Option<Uuid>,
limit: Option<i64>,
}
fn request_path(path: &str, raw_query: Option<&str>) -> String {
match raw_query {
Some(query) if !query.is_empty() => format!("{path}?{query}"),
_ => path.to_string(),
}
}
#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers
async fn authorize_workflow_read(
state: &Arc<AppState>,
headers: &HeaderMap,
path: &str,
raw_query: Option<&str>,
workflow_id: Uuid,
) -> Result<TenantContext, Response> {
let tenant = crate::nip_fi_shadow::bind_tenant(state, headers)
.await
.ok_or_else(|| {
api_error(
StatusCode::NOT_FOUND,
"relay: no community is configured for this host",
)
.into_response()
})?;
let path_with_query = request_path(path, raw_query);
let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query);
// In NIP-FI enforce/deny-protected mode a real NIP-98 event is mandatory —
// the X-Pubkey dev-mode fallback must never satisfy the pairing requirement.
// [NIP-FI.md:594-607, FI-TRACE-HTTP-INGRESS]
let nip_fi_active = state.config.nip_fi.mode.restricts();
// NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM]
let require_auth = state.config.require_auth_token || nip_fi_active;
let admission = admit_nip_fi_http_on_state(
state,
headers,
bridge::make_nip98_closure_for_admission(
headers.clone(),
"GET",
url,
None,
require_auth,
false,
),
)?;
let pubkey = *admission.proven_pubkey();
let (event_id_bytes, signed_created_at) = admission.into_extra();
bridge::enforce_http_admission(state, &tenant, &pubkey)
.await
.map_err(|e| e.into_response())?;
bridge::check_nip98_replay(state, &tenant, event_id_bytes)
.await
.map_err(|e| e.into_response())?;
let pubkey_bytes = pubkey.to_bytes().to_vec();
let auth_tag = super::relay_members::extract_auth_tag_header(headers);
super::relay_members::enforce_relay_membership(
state,
tenant.community(),
&pubkey_bytes,
auth_tag,
signed_created_at,
)
.await
.map_err(|e| e.into_response())?;
let workflow = state
.db
.get_workflow(tenant.community(), workflow_id)
.await
.map_err(|error| match error {
buzz_db::error::DbError::NotFound(_) => {
api_error(StatusCode::NOT_FOUND, "workflow not found").into_response()
}
other => internal_error(&format!("get workflow for run read: {other}")).into_response(),
})?;
let channel_id = workflow.channel_id.ok_or_else(|| {
api_error(StatusCode::FORBIDDEN, "workflow is not channel-scoped").into_response()
})?;
let accessible = state
.get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes)
.await
.map_err(|error| {
internal_error(&format!("workflow channel access lookup: {error}")).into_response()
})?;
if !accessible.contains(&channel_id) {
return Err(api_error(StatusCode::FORBIDDEN, "workflow is not accessible").into_response());
}
Ok(tenant)
}
/// `GET /workflows/{workflow_id}/runs` — one authorized, keyset-paginated page.
pub async fn workflow_runs(
State(state): State<Arc<AppState>>,
Path(workflow_id): Path<Uuid>,
headers: HeaderMap,
RawQuery(raw_query): RawQuery,
) -> Response {
workflow_runs_inner(state, workflow_id, headers, raw_query)
.await
.into_response()
}
async fn workflow_runs_inner(
state: Arc<AppState>,
workflow_id: Uuid,
headers: HeaderMap,
raw_query: Option<String>,
) -> Result<Json<Value>, Response> {
// Admission first: NIP-98 + NIP-FI must fire before any application-level
// validation — including query-string parsing — so the denial contract wins
// over request-validation errors. [FI-TRACE-HTTP-INGRESS]
// Raw query is preserved here; parsing happens after admission.
let path = format!("/workflows/{workflow_id}/runs");
let tenant =
authorize_workflow_read(&state, &headers, &path, raw_query.as_deref(), workflow_id).await?;
// Parse query string after admission so malformed params (e.g. ?limit=abc)
// cannot 400 before the NIP-FI gate fires. A parse failure after
// admission is a caller error (400); defaulting silently would change
// query semantics. [FI-TRACE-HTTP-INGRESS]
let query: RunsQuery =
parse_query_or_400(raw_query.as_deref()).map_err(|e| e.into_response())?;
if query.before.is_some() != query.before_id.is_some() {
return Err(api_error(
StatusCode::BAD_REQUEST,
"before and before_id must be supplied together",
)
.into_response());
}
let limit = query.limit.unwrap_or(DEFAULT_RUN_LIMIT);
if !(1..=MAX_RUN_LIMIT).contains(&limit) {
return Err(
api_error(StatusCode::BAD_REQUEST, "limit must be between 1 and 100").into_response(),
);
}
let mut rows = state
.db
.list_workflow_runs_page(
tenant.community(),
workflow_id,
query.before,
query.before_id,
limit + 1,
)
.await
.map_err(|error| internal_error(&format!("list workflow runs: {error}")).into_response())?;
let has_more = rows.len() > limit as usize;
rows.truncate(limit as usize);
let next = if has_more {
rows.last().map(|last| {
serde_json::json!({
"before": last.created_at,
"before_id": last.id,
})
})
} else {
None
};
Ok(Json(serde_json::json!({
"runs": rows.iter().map(run_json).collect::<Vec<_>>(),
"next": next,
})))
}
/// `GET /workflows/{workflow_id}/runs/{run_id}/approvals` — approvals for a run.
pub async fn run_approvals(
State(state): State<Arc<AppState>>,
Path((workflow_id, run_id)): Path<(Uuid, Uuid)>,
headers: HeaderMap,
) -> Response {
run_approvals_inner(state, workflow_id, run_id, headers)
.await
.into_response()
}
async fn run_approvals_inner(
state: Arc<AppState>,
workflow_id: Uuid,
run_id: Uuid,
headers: HeaderMap,
) -> Result<Json<Value>, Response> {
let path = format!("/workflows/{workflow_id}/runs/{run_id}/approvals");
let tenant = authorize_workflow_read(&state, &headers, &path, None, workflow_id).await?;
let run = state
.db
.get_workflow_run(tenant.community(), run_id)
.await
.map_err(|error| match error {
buzz_db::error::DbError::NotFound(_) => {
api_error(StatusCode::NOT_FOUND, "workflow run not found").into_response()
}
other => internal_error(&format!("get workflow run for approval read: {other}"))
.into_response(),
})?;
if run.workflow_id != workflow_id {
return Err(api_error(StatusCode::NOT_FOUND, "workflow run not found").into_response());
}
let approvals = state
.db
.get_run_approvals(tenant.community(), workflow_id, run_id)
.await
.map_err(|error| internal_error(&format!("list run approvals: {error}")).into_response())?;
Ok(Json(serde_json::json!({
"approvals": approvals.iter().map(approval_json).collect::<Vec<_>>(),
})))
}
fn run_json(run: &buzz_db::workflow::WorkflowRunRecord) -> Value {
serde_json::json!({
"id": run.id,
"workflow_id": run.workflow_id,
"status": run.status,
"current_step": run.current_step,
"execution_trace": run.execution_trace,
"started_at": run.started_at.map(|value| value.timestamp()),
"completed_at": run.completed_at.map(|value| value.timestamp()),
"error_code": run.error_code,
"error_message": run.error_message,
"created_at": run.created_at.timestamp(),
})
}
fn approval_json(approval: &buzz_db::workflow::ApprovalRecord) -> Value {
serde_json::json!({
"approval_ref": hex::encode(&approval.token),
"workflow_id": approval.workflow_id,
"run_id": approval.run_id,
"step_id": approval.step_id,
"step_index": approval.step_index,
"approver_spec": approval.approver_spec,
"status": approval.status,
"approver_pubkey": approval.approver_pubkey.as_ref().map(hex::encode),
"note": approval.note,
"expires_at": approval.expires_at,
"created_at": approval.created_at.timestamp(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_path_preserves_signed_query_verbatim() {
assert_eq!(
request_path("/workflows/id/runs", Some("limit=20&before_id=abc")),
"/workflows/id/runs?limit=20&before_id=abc"
);
assert_eq!(
request_path("/workflows/id/runs", None),
"/workflows/id/runs"
);
}
#[test]
fn approval_wire_does_not_expose_hash_as_token() {
let approval = buzz_db::workflow::ApprovalRecord {
token: vec![0xab; 32],
workflow_id: Uuid::new_v4(),
run_id: Uuid::new_v4(),
step_id: "review".to_string(),
step_index: 1,
approver_spec: "any".to_string(),
status: buzz_db::workflow::ApprovalStatus::Pending,
approver_pubkey: None,
note: None,
expires_at: Utc::now(),
created_at: Utc::now(),
};
let wire = approval_json(&approval);
assert!(wire.get("token").is_none());
assert_eq!(wire["approval_ref"], hex::encode([0xab; 32]));
}
}