//! 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>, before_id: Option, limit: Option, } 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, headers: &HeaderMap, path: &str, raw_query: Option<&str>, workflow_id: Uuid, ) -> Result { 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>, Path(workflow_id): Path, 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, workflow_id: Uuid, headers: HeaderMap, raw_query: Option, ) -> Result, 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::>(), "next": next, }))) } /// `GET /workflows/{workflow_id}/runs/{run_id}/approvals` — approvals for a run. pub async fn run_approvals( State(state): State>, 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, workflow_id: Uuid, run_id: Uuid, headers: HeaderMap, ) -> Result, 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::>(), }))) } 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])); } }