mirror of
https://github.com/block/buzz.git
synced 2026-11-05 08:51:10 +00:00
Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson <loganj@squareup.com>
1541 lines
55 KiB
Rust
1541 lines
55 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::{
|
|
body::Bytes,
|
|
extract::{Path, Query, RawQuery, State},
|
|
http::{HeaderMap, StatusCode},
|
|
response::Json,
|
|
};
|
|
use chrono::{DateTime, Utc};
|
|
use serde::Deserialize;
|
|
use serde_json::Value;
|
|
use uuid::Uuid;
|
|
|
|
use buzz_core::{
|
|
workflow_delivery::{
|
|
WorkflowDeliveryBinding, WorkflowDeliveryCause, WorkflowDeliveryId, WorkflowDeliveryReceipt,
|
|
},
|
|
TenantContext,
|
|
};
|
|
|
|
use crate::{
|
|
api::{api_error, bridge, internal_error},
|
|
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(),
|
|
}
|
|
}
|
|
|
|
async fn authorize_workflow_read(
|
|
state: &Arc<AppState>,
|
|
headers: &HeaderMap,
|
|
path: &str,
|
|
raw_query: Option<&str>,
|
|
workflow_id: Uuid,
|
|
allow_immutable_owner: bool,
|
|
) -> Result<TenantContext, (StatusCode, Json<Value>)> {
|
|
let raw_host = headers
|
|
.get(axum::http::header::HOST)
|
|
.and_then(|value| value.to_str().ok())
|
|
.unwrap_or("");
|
|
let tenant = crate::tenant::bind_community(&state.db, raw_host)
|
|
.await
|
|
.map_err(|_| {
|
|
api_error(
|
|
StatusCode::NOT_FOUND,
|
|
"relay: no community is configured for this host",
|
|
)
|
|
})?;
|
|
|
|
let path_with_query = request_path(path, raw_query);
|
|
let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query);
|
|
let (pubkey, event_id_bytes) =
|
|
bridge::verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?;
|
|
bridge::enforce_http_admission(state, &tenant, &pubkey).await?;
|
|
bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?;
|
|
|
|
let pubkey_bytes = pubkey.to_bytes().to_vec();
|
|
let auth_tag = headers
|
|
.get("x-auth-tag")
|
|
.and_then(|value| value.to_str().ok());
|
|
super::relay_members::enforce_relay_membership(
|
|
state,
|
|
tenant.community(),
|
|
&pubkey_bytes,
|
|
auth_tag,
|
|
)
|
|
.await?;
|
|
|
|
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")
|
|
}
|
|
other => internal_error(&format!("get workflow for run read: {other}")),
|
|
})?;
|
|
let channel_id = workflow
|
|
.channel_id
|
|
.ok_or_else(|| api_error(StatusCode::FORBIDDEN, "workflow is not channel-scoped"))?;
|
|
let accessible = state
|
|
.get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes)
|
|
.await
|
|
.map_err(|error| internal_error(&format!("workflow channel access lookup: {error}")))?;
|
|
if !accessible.contains(&channel_id) {
|
|
let controls = allow_immutable_owner
|
|
&& (workflow.owner_pubkey == pubkey_bytes
|
|
|| state
|
|
.db
|
|
.is_agent_owner(tenant.community(), &workflow.owner_pubkey, &pubkey_bytes)
|
|
.await
|
|
.map_err(|error| internal_error(&format!("workflow owner lookup: {error}")))?);
|
|
if !controls {
|
|
return Err(api_error(
|
|
StatusCode::FORBIDDEN,
|
|
"workflow is not accessible",
|
|
));
|
|
}
|
|
}
|
|
|
|
Ok(tenant)
|
|
}
|
|
|
|
/// `GET /workflows/{workflow_id}/revision` — current signed revision for an
|
|
/// authorized channel reader or the managed agent's immutable human owner.
|
|
///
|
|
/// This narrow endpoint does not grant channel visibility; it returns only the
|
|
/// exact owner-signed definition event needed to construct a revision-bound
|
|
/// manual trigger.
|
|
pub async fn workflow_revision(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(workflow_id): Path<Uuid>,
|
|
headers: HeaderMap,
|
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
|
let path = format!("/workflows/{workflow_id}/revision");
|
|
let tenant = authorize_workflow_read(&state, &headers, &path, None, workflow_id, true).await?;
|
|
let workflow = state
|
|
.db
|
|
.get_workflow(tenant.community(), workflow_id)
|
|
.await
|
|
.map_err(|_| api_error(StatusCode::NOT_FOUND, "workflow not found"))?;
|
|
let revision = workflow.definition_event_id.as_deref().ok_or_else(|| {
|
|
api_error(
|
|
StatusCode::CONFLICT,
|
|
"owner-signed workflow revision is unavailable",
|
|
)
|
|
})?;
|
|
let event = state
|
|
.db
|
|
.get_event_by_id(tenant.community(), revision)
|
|
.await
|
|
.map_err(|error| internal_error(&format!("get workflow revision: {error}")))?
|
|
.ok_or_else(|| api_error(StatusCode::CONFLICT, "workflow revision is unavailable"))?;
|
|
Ok(Json(serde_json::to_value(event.event).map_err(
|
|
|error| internal_error(&format!("serialize workflow revision: {error}")),
|
|
)?))
|
|
}
|
|
|
|
/// `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,
|
|
Query(query): Query<RunsQuery>,
|
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
|
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",
|
|
));
|
|
}
|
|
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",
|
|
));
|
|
}
|
|
|
|
let path = format!("/workflows/{workflow_id}/runs");
|
|
let tenant = authorize_workflow_read(
|
|
&state,
|
|
&headers,
|
|
&path,
|
|
raw_query.as_deref(),
|
|
workflow_id,
|
|
false,
|
|
)
|
|
.await?;
|
|
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}")))?;
|
|
|
|
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,
|
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
|
let path = format!("/workflows/{workflow_id}/runs/{run_id}/approvals");
|
|
let tenant = authorize_workflow_read(&state, &headers, &path, None, workflow_id, false).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")
|
|
}
|
|
other => internal_error(&format!("get workflow run for approval read: {other}")),
|
|
})?;
|
|
if run.workflow_id != workflow_id {
|
|
return Err(api_error(StatusCode::NOT_FOUND, "workflow run not found"));
|
|
}
|
|
|
|
let approvals = state
|
|
.db
|
|
.get_run_approvals(tenant.community(), workflow_id, run_id)
|
|
.await
|
|
.map_err(|error| internal_error(&format!("list run approvals: {error}")))?;
|
|
Ok(Json(serde_json::json!({
|
|
"approvals": approvals.iter().map(approval_json).collect::<Vec<_>>(),
|
|
})))
|
|
}
|
|
|
|
const DEFAULT_DELIVERY_LEASE_SECONDS: i64 = 60;
|
|
const MIN_DELIVERY_LEASE_SECONDS: i64 = 15;
|
|
const MAX_DELIVERY_LEASE_SECONDS: i64 = 300;
|
|
|
|
/// Full immutable selector for a specific durable delivery.
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct DeliveryBindingRequest {
|
|
community_id: Uuid,
|
|
workflow_id: Uuid,
|
|
run_id: Uuid,
|
|
step_id: String,
|
|
target_pubkey: String,
|
|
definition_event_id: String,
|
|
message_event_id: String,
|
|
cause: DeliveryCauseRequest,
|
|
}
|
|
|
|
/// Trigger authority identity carried by a delivery binding.
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
|
pub enum DeliveryCauseRequest {
|
|
/// Exact signed event which caused the run.
|
|
Event {
|
|
/// Trigger event identifier.
|
|
event_id: String,
|
|
},
|
|
/// Exact scheduled firing slot which caused the run.
|
|
Schedule {
|
|
/// Authoritative Unix-second schedule slot.
|
|
scheduled_for_unix_seconds: i64,
|
|
},
|
|
/// Opaque server-side webhook invocation identity.
|
|
Webhook {
|
|
/// Invocation identifier; no webhook secret or payload.
|
|
invocation_id: Uuid,
|
|
},
|
|
}
|
|
|
|
/// Request to claim a specific delivery or poll the oldest pending delivery.
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ClaimDeliveryRequest {
|
|
#[serde(default)]
|
|
delivery_id: Option<Uuid>,
|
|
#[serde(default)]
|
|
expected: Option<DeliveryBindingRequest>,
|
|
#[serde(default = "default_delivery_lease_seconds")]
|
|
lease_seconds: i64,
|
|
}
|
|
|
|
/// Fenced lease capability presented to read, renew, or finish a delivery.
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct DeliveryLeaseRequest {
|
|
lease_generation: i64,
|
|
binding: DeliveryBindingRequest,
|
|
}
|
|
|
|
/// Request to extend a delivery lease.
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct RenewDeliveryRequest {
|
|
lease_generation: i64,
|
|
binding: DeliveryBindingRequest,
|
|
#[serde(default = "default_delivery_lease_seconds")]
|
|
lease_seconds: i64,
|
|
}
|
|
|
|
/// Terminal disposition for a delivery.
|
|
#[derive(Debug, Deserialize, Clone, Copy)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum DeliveryDisposition {
|
|
/// Delivery work completed successfully.
|
|
Finished,
|
|
/// Delivery work failed permanently.
|
|
Failed,
|
|
}
|
|
|
|
/// Request to settle a delivery under its fenced lease.
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct FinishDeliveryRequest {
|
|
lease_generation: i64,
|
|
binding: DeliveryBindingRequest,
|
|
disposition: DeliveryDisposition,
|
|
}
|
|
|
|
fn default_delivery_lease_seconds() -> i64 {
|
|
DEFAULT_DELIVERY_LEASE_SECONDS
|
|
}
|
|
|
|
fn validate_lease_seconds(value: i64) -> Result<i64, (StatusCode, Json<Value>)> {
|
|
if !(MIN_DELIVERY_LEASE_SECONDS..=MAX_DELIVERY_LEASE_SECONDS).contains(&value) {
|
|
return Err(api_error(
|
|
StatusCode::BAD_REQUEST,
|
|
"lease_seconds must be between 15 and 300",
|
|
));
|
|
}
|
|
Ok(value)
|
|
}
|
|
|
|
fn parse_event_id(value: &str, field: &str) -> Result<nostr::EventId, (StatusCode, Json<Value>)> {
|
|
nostr::EventId::from_hex(value)
|
|
.map_err(|_| api_error(StatusCode::BAD_REQUEST, &format!("invalid {field}")))
|
|
}
|
|
|
|
fn parse_delivery_binding(
|
|
request: DeliveryBindingRequest,
|
|
tenant: &TenantContext,
|
|
authenticated: nostr::PublicKey,
|
|
) -> Result<WorkflowDeliveryBinding, (StatusCode, Json<Value>)> {
|
|
if request.community_id != *tenant.community().as_uuid() {
|
|
return Err(api_error(StatusCode::NOT_FOUND, "delivery not found"));
|
|
}
|
|
let target = nostr::PublicKey::from_hex(&request.target_pubkey)
|
|
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid target_pubkey"))?;
|
|
if target != authenticated {
|
|
return Err(api_error(StatusCode::NOT_FOUND, "delivery not found"));
|
|
}
|
|
let cause = match request.cause {
|
|
DeliveryCauseRequest::Event { event_id } => {
|
|
WorkflowDeliveryCause::Event(parse_event_id(&event_id, "cause.event_id")?)
|
|
}
|
|
DeliveryCauseRequest::Schedule {
|
|
scheduled_for_unix_seconds,
|
|
} => WorkflowDeliveryCause::Schedule {
|
|
scheduled_for_unix_seconds,
|
|
},
|
|
DeliveryCauseRequest::Webhook { invocation_id } => {
|
|
WorkflowDeliveryCause::Webhook { invocation_id }
|
|
}
|
|
};
|
|
WorkflowDeliveryBinding::new(
|
|
tenant.community(),
|
|
request.workflow_id,
|
|
request.run_id,
|
|
request.step_id,
|
|
target,
|
|
parse_event_id(&request.definition_event_id, "definition_event_id")?,
|
|
parse_event_id(&request.message_event_id, "message_event_id")?,
|
|
cause,
|
|
)
|
|
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid delivery binding"))
|
|
}
|
|
|
|
async fn authorize_delivery_request(
|
|
state: &Arc<AppState>,
|
|
headers: &HeaderMap,
|
|
path: &str,
|
|
body: &[u8],
|
|
) -> Result<(TenantContext, nostr::PublicKey), (StatusCode, Json<Value>)> {
|
|
let raw_host = headers
|
|
.get(axum::http::header::HOST)
|
|
.and_then(|value| value.to_str().ok())
|
|
.unwrap_or("");
|
|
let tenant = crate::tenant::bind_community(&state.db, raw_host)
|
|
.await
|
|
.map_err(|_| api_error(StatusCode::NOT_FOUND, "delivery not found"))?;
|
|
let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path);
|
|
let (pubkey, event_id) = bridge::verify_bridge_auth_with_options(
|
|
headers,
|
|
"POST",
|
|
&url,
|
|
Some(body),
|
|
state.config.require_auth_token,
|
|
true,
|
|
)?;
|
|
bridge::enforce_http_admission(state, &tenant, &pubkey).await?;
|
|
bridge::check_nip98_replay(state, &tenant, event_id).await?;
|
|
let pubkey_bytes = pubkey.to_bytes();
|
|
let auth_tag = headers
|
|
.get("x-auth-tag")
|
|
.and_then(|value| value.to_str().ok());
|
|
super::relay_members::enforce_relay_membership(
|
|
state,
|
|
tenant.community(),
|
|
&pubkey_bytes,
|
|
auth_tag,
|
|
)
|
|
.await?;
|
|
let (_, owner) = state
|
|
.db
|
|
.get_agent_channel_policy(tenant.community(), &pubkey_bytes)
|
|
.await
|
|
.map_err(|error| internal_error(&format!("delivery target lookup: {error}")))?
|
|
.ok_or_else(|| api_error(StatusCode::NOT_FOUND, "delivery not found"))?;
|
|
if owner.is_none() {
|
|
return Err(api_error(StatusCode::NOT_FOUND, "delivery not found"));
|
|
}
|
|
Ok((tenant, pubkey))
|
|
}
|
|
|
|
async fn validate_binding_channel(
|
|
state: &Arc<AppState>,
|
|
tenant: &TenantContext,
|
|
binding: &WorkflowDeliveryBinding,
|
|
) -> Result<(), (StatusCode, Json<Value>)> {
|
|
let message = state
|
|
.db
|
|
.get_event_by_id(tenant.community(), binding.message_event_id().as_bytes())
|
|
.await
|
|
.map_err(|error| internal_error(&format!("delivery message lookup: {error}")))?
|
|
.ok_or_else(|| api_error(StatusCode::NOT_FOUND, "delivery not found"))?;
|
|
let Some(channel_id) = message.channel_id else {
|
|
return Err(api_error(StatusCode::NOT_FOUND, "delivery not found"));
|
|
};
|
|
let workflow = state
|
|
.db
|
|
.get_workflow(tenant.community(), binding.workflow_id())
|
|
.await
|
|
.map_err(|_| api_error(StatusCode::NOT_FOUND, "delivery not found"))?;
|
|
if workflow.channel_id != Some(channel_id) {
|
|
return Err(api_error(StatusCode::NOT_FOUND, "delivery not found"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn current_bound_delivery(
|
|
state: &Arc<AppState>,
|
|
tenant: &TenantContext,
|
|
target: nostr::PublicKey,
|
|
delivery_id: WorkflowDeliveryId,
|
|
lease_generation: i64,
|
|
requested: DeliveryBindingRequest,
|
|
require_live_claim: bool,
|
|
) -> Result<buzz_db::workflow::WorkflowAgentDeliveryRecord, (StatusCode, Json<Value>)> {
|
|
let delivery = state
|
|
.db
|
|
.get_workflow_agent_delivery(tenant.community(), delivery_id)
|
|
.await
|
|
.map_err(|error| internal_error(&format!("read workflow delivery: {error}")))?
|
|
.ok_or_else(|| api_error(StatusCode::NOT_FOUND, "delivery not found"))?;
|
|
let binding = parse_delivery_binding(requested, tenant, target)?;
|
|
let live_claim = delivery.status == buzz_db::workflow::WorkflowDeliveryStatus::Claimed
|
|
&& delivery
|
|
.lease_until
|
|
.is_some_and(|until| until >= Utc::now());
|
|
if delivery.binding != binding
|
|
|| delivery.lease_generation != lease_generation
|
|
|| (require_live_claim && !live_claim)
|
|
{
|
|
return Err(api_error(
|
|
StatusCode::CONFLICT,
|
|
"delivery lease is not current",
|
|
));
|
|
}
|
|
// Resolve message/channel authority only after the caller has demonstrated
|
|
// the complete delivery binding. Otherwise this endpoint becomes an event
|
|
// existence oracle for arbitrary managed-agent identities.
|
|
validate_binding_channel(state, tenant, &binding).await?;
|
|
Ok(delivery)
|
|
}
|
|
|
|
fn lease_for(
|
|
tenant: &TenantContext,
|
|
target: nostr::PublicKey,
|
|
delivery_id: WorkflowDeliveryId,
|
|
lease_generation: i64,
|
|
) -> buzz_db::workflow::WorkflowDeliveryLease {
|
|
buzz_db::workflow::WorkflowDeliveryLease {
|
|
community_id: tenant.community(),
|
|
delivery_id,
|
|
target_pubkey: target,
|
|
lease_generation,
|
|
// C's mutation queries fence on community/id/target/generation and the
|
|
// database clock. This field is returned state, not client authority.
|
|
lease_until: Utc::now(),
|
|
}
|
|
}
|
|
|
|
/// `POST /workflows/agent-deliveries/claim` — claim only as the immutable
|
|
/// managed-agent target. A wake can supply identifiers but grants no authority.
|
|
pub async fn claim_agent_delivery(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
body: Bytes,
|
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
|
let path = "/workflows/agent-deliveries/claim";
|
|
let (tenant, target) = authorize_delivery_request(&state, &headers, path, &body).await?;
|
|
let request: ClaimDeliveryRequest = serde_json::from_slice(&body)
|
|
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid delivery claim JSON"))?;
|
|
let lease_seconds = validate_lease_seconds(request.lease_seconds)?;
|
|
if request.delivery_id.is_none() && request.expected.is_some() {
|
|
return Err(api_error(
|
|
StatusCode::BAD_REQUEST,
|
|
"expected binding requires delivery_id",
|
|
));
|
|
}
|
|
let delivery_id = request.delivery_id.map(WorkflowDeliveryId::from_uuid);
|
|
let expected = request
|
|
.expected
|
|
.map(|expected| parse_delivery_binding(expected, &tenant, target))
|
|
.transpose()?;
|
|
if let Some(binding) = expected.as_ref() {
|
|
validate_binding_channel(&state, &tenant, binding).await?;
|
|
}
|
|
let claimed = state
|
|
.db
|
|
.claim_workflow_agent_delivery(
|
|
tenant.community(),
|
|
&target,
|
|
delivery_id,
|
|
expected.as_ref(),
|
|
lease_seconds,
|
|
)
|
|
.await
|
|
.map_err(|error| internal_error(&format!("claim workflow delivery: {error}")))?;
|
|
let Some((lease, delivery)) = claimed else {
|
|
return Err(api_error(StatusCode::NOT_FOUND, "delivery not found"));
|
|
};
|
|
delivery_response(&state, &tenant, &target, &lease, &delivery).await
|
|
}
|
|
|
|
/// `POST /workflows/agent-deliveries/{id}/read` — return private execution
|
|
/// inputs only to the exact target holding the current live fencing token.
|
|
pub async fn read_agent_delivery(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(delivery_id): Path<Uuid>,
|
|
headers: HeaderMap,
|
|
body: Bytes,
|
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
|
let path = format!("/workflows/agent-deliveries/{delivery_id}/read");
|
|
let (tenant, target) = authorize_delivery_request(&state, &headers, &path, &body).await?;
|
|
let request: DeliveryLeaseRequest = serde_json::from_slice(&body)
|
|
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid delivery read JSON"))?;
|
|
let id = WorkflowDeliveryId::from_uuid(delivery_id);
|
|
let delivery = current_bound_delivery(
|
|
&state,
|
|
&tenant,
|
|
target,
|
|
id,
|
|
request.lease_generation,
|
|
request.binding,
|
|
true,
|
|
)
|
|
.await?;
|
|
let lease = lease_for(&tenant, target, id, request.lease_generation);
|
|
delivery_response(&state, &tenant, &target, &lease, &delivery).await
|
|
}
|
|
|
|
/// `POST /workflows/agent-deliveries/{id}/renew` — extend the current fenced lease.
|
|
pub async fn renew_agent_delivery(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(delivery_id): Path<Uuid>,
|
|
headers: HeaderMap,
|
|
body: Bytes,
|
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
|
let path = format!("/workflows/agent-deliveries/{delivery_id}/renew");
|
|
let (tenant, target) = authorize_delivery_request(&state, &headers, &path, &body).await?;
|
|
let request: RenewDeliveryRequest = serde_json::from_slice(&body)
|
|
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid delivery renewal JSON"))?;
|
|
let lease_seconds = validate_lease_seconds(request.lease_seconds)?;
|
|
let id = WorkflowDeliveryId::from_uuid(delivery_id);
|
|
current_bound_delivery(
|
|
&state,
|
|
&tenant,
|
|
target,
|
|
id,
|
|
request.lease_generation,
|
|
request.binding,
|
|
true,
|
|
)
|
|
.await?;
|
|
let lease = lease_for(&tenant, target, id, request.lease_generation);
|
|
match state
|
|
.db
|
|
.renew_workflow_agent_delivery(&lease, lease_seconds)
|
|
.await
|
|
.map_err(|error| internal_error(&format!("renew workflow delivery: {error}")))?
|
|
{
|
|
buzz_db::workflow::WorkflowDeliveryRenewOutcome::Renewed(lease_until) => Ok(Json(
|
|
serde_json::json!({"renewed": true, "lease_until": lease_until}),
|
|
)),
|
|
buzz_db::workflow::WorkflowDeliveryRenewOutcome::LeaseLost => Err(api_error(
|
|
StatusCode::CONFLICT,
|
|
"delivery lease is not current",
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// `POST /workflows/agent-deliveries/{id}/finish` — settle once, with stable
|
|
/// acknowledgement only when a terminal replay has the identical disposition.
|
|
pub async fn finish_agent_delivery(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(delivery_id): Path<Uuid>,
|
|
headers: HeaderMap,
|
|
body: Bytes,
|
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
|
let path = format!("/workflows/agent-deliveries/{delivery_id}/finish");
|
|
let (tenant, target) = authorize_delivery_request(&state, &headers, &path, &body).await?;
|
|
let request: FinishDeliveryRequest = serde_json::from_slice(&body)
|
|
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid delivery finish JSON"))?;
|
|
let id = WorkflowDeliveryId::from_uuid(delivery_id);
|
|
current_bound_delivery(
|
|
&state,
|
|
&tenant,
|
|
target,
|
|
id,
|
|
request.lease_generation,
|
|
request.binding,
|
|
false,
|
|
)
|
|
.await?;
|
|
let requested = match request.disposition {
|
|
DeliveryDisposition::Finished => buzz_db::workflow::WorkflowDeliveryOutcome::Finished,
|
|
DeliveryDisposition::Failed => buzz_db::workflow::WorkflowDeliveryOutcome::Failed,
|
|
};
|
|
let lease = lease_for(&tenant, target, id, request.lease_generation);
|
|
match state
|
|
.db
|
|
.finish_workflow_agent_delivery(&lease, requested)
|
|
.await
|
|
.map_err(|error| internal_error(&format!("finish workflow delivery: {error}")))?
|
|
{
|
|
buzz_db::workflow::WorkflowDeliveryFinishOutcome::Settled(_) => Ok(Json(
|
|
serde_json::json!({"finished": true, "replayed": false}),
|
|
)),
|
|
buzz_db::workflow::WorkflowDeliveryFinishOutcome::AlreadyTerminal(status)
|
|
if terminal_matches(status, request.disposition) =>
|
|
{
|
|
Ok(Json(
|
|
serde_json::json!({"finished": true, "replayed": true}),
|
|
))
|
|
}
|
|
buzz_db::workflow::WorkflowDeliveryFinishOutcome::AlreadyTerminal(_)
|
|
| buzz_db::workflow::WorkflowDeliveryFinishOutcome::LeaseLost => Err(api_error(
|
|
StatusCode::CONFLICT,
|
|
"delivery lease is not current",
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn terminal_matches(
|
|
status: buzz_db::workflow::WorkflowDeliveryStatus,
|
|
disposition: DeliveryDisposition,
|
|
) -> bool {
|
|
matches!(
|
|
(status, disposition),
|
|
(
|
|
buzz_db::workflow::WorkflowDeliveryStatus::Finished,
|
|
DeliveryDisposition::Finished
|
|
) | (
|
|
buzz_db::workflow::WorkflowDeliveryStatus::Failed,
|
|
DeliveryDisposition::Failed
|
|
)
|
|
)
|
|
}
|
|
|
|
async fn validate_delivery_cause_authority(
|
|
state: &Arc<AppState>,
|
|
tenant: &TenantContext,
|
|
binding: &WorkflowDeliveryBinding,
|
|
) -> Result<(), (StatusCode, Json<Value>)> {
|
|
let authoritative_run = match binding.cause() {
|
|
WorkflowDeliveryCause::Event(event_id) => {
|
|
let event = state
|
|
.db
|
|
.get_event_by_id(tenant.community(), event_id.as_bytes())
|
|
.await
|
|
.map_err(|error| internal_error(&format!("delivery cause lookup: {error}")))?
|
|
.ok_or_else(|| {
|
|
api_error(StatusCode::CONFLICT, "delivery authority is unavailable")
|
|
})?;
|
|
if event.event.verify().is_err() {
|
|
return Err(api_error(
|
|
StatusCode::CONFLICT,
|
|
"delivery authority is unavailable",
|
|
));
|
|
}
|
|
return Ok(());
|
|
}
|
|
WorkflowDeliveryCause::Schedule {
|
|
scheduled_for_unix_seconds,
|
|
} => {
|
|
let scheduled_for =
|
|
chrono::DateTime::<Utc>::from_timestamp(*scheduled_for_unix_seconds, 0)
|
|
.ok_or_else(|| {
|
|
api_error(StatusCode::CONFLICT, "delivery authority is unavailable")
|
|
})?;
|
|
state
|
|
.db
|
|
.get_scheduled_workflow_fire_run(
|
|
tenant.community(),
|
|
binding.workflow_id(),
|
|
scheduled_for,
|
|
)
|
|
.await
|
|
.map_err(|error| internal_error(&format!("schedule authority lookup: {error}")))?
|
|
}
|
|
WorkflowDeliveryCause::Webhook { invocation_id } => state
|
|
.db
|
|
.get_workflow_webhook_invocation_run(
|
|
tenant.community(),
|
|
binding.workflow_id(),
|
|
*invocation_id,
|
|
)
|
|
.await
|
|
.map_err(|error| internal_error(&format!("webhook authority lookup: {error}")))?,
|
|
};
|
|
if authoritative_run != Some(binding.run_id()) {
|
|
return Err(api_error(
|
|
StatusCode::CONFLICT,
|
|
"delivery authority is unavailable",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn delivery_response(
|
|
state: &Arc<AppState>,
|
|
tenant: &TenantContext,
|
|
target: &nostr::PublicKey,
|
|
lease: &buzz_db::workflow::WorkflowDeliveryLease,
|
|
delivery: &buzz_db::workflow::WorkflowAgentDeliveryRecord,
|
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
|
if delivery.binding.community_id() != tenant.community()
|
|
|| delivery.binding.target_pubkey() != *target
|
|
|| delivery.id != lease.delivery_id
|
|
|| delivery.lease_generation != lease.lease_generation
|
|
{
|
|
return Err(api_error(StatusCode::NOT_FOUND, "delivery not found"));
|
|
}
|
|
let run = state
|
|
.db
|
|
.get_workflow_run(tenant.community(), delivery.binding.run_id())
|
|
.await
|
|
.map_err(|error| internal_error(&format!("delivery run lookup: {error}")))?;
|
|
if run.workflow_id != delivery.binding.workflow_id()
|
|
|| run.definition_event_id.as_deref()
|
|
!= Some(delivery.binding.definition_event_id().as_bytes())
|
|
{
|
|
return Err(api_error(
|
|
StatusCode::CONFLICT,
|
|
"delivery binding is unavailable",
|
|
));
|
|
}
|
|
let definition = state
|
|
.db
|
|
.get_event_by_id(
|
|
tenant.community(),
|
|
delivery.binding.definition_event_id().as_bytes(),
|
|
)
|
|
.await
|
|
.map_err(|error| internal_error(&format!("delivery definition lookup: {error}")))?
|
|
.ok_or_else(|| api_error(StatusCode::CONFLICT, "delivery binding is unavailable"))?;
|
|
let message = state
|
|
.db
|
|
.get_event_by_id(
|
|
tenant.community(),
|
|
delivery.binding.message_event_id().as_bytes(),
|
|
)
|
|
.await
|
|
.map_err(|error| internal_error(&format!("delivery message lookup: {error}")))?
|
|
.ok_or_else(|| api_error(StatusCode::CONFLICT, "delivery binding is unavailable"))?;
|
|
validate_delivery_cause_authority(state, tenant, &delivery.binding).await?;
|
|
let receipt =
|
|
WorkflowDeliveryReceipt::new(delivery.id, delivery.binding.clone(), &message.event)
|
|
.and_then(|receipt| receipt.sign(&state.relay_keypair, &message.event))
|
|
.map_err(|error| internal_error(&format!("sign delivery receipt: {error}")))?;
|
|
Ok(Json(serde_json::json!({
|
|
"delivery": delivery_json(delivery, lease),
|
|
"definition_event": definition.event,
|
|
"message_event": message.event,
|
|
"receipt_event": receipt,
|
|
})))
|
|
}
|
|
|
|
fn delivery_json(
|
|
delivery: &buzz_db::workflow::WorkflowAgentDeliveryRecord,
|
|
lease: &buzz_db::workflow::WorkflowDeliveryLease,
|
|
) -> Value {
|
|
let binding = &delivery.binding;
|
|
serde_json::json!({
|
|
"id": delivery.id.as_uuid(),
|
|
"community_id": binding.community_id().as_uuid(),
|
|
"workflow_id": binding.workflow_id(),
|
|
"run_id": binding.run_id(),
|
|
"step_id": binding.step_id(),
|
|
"target_pubkey": binding.target_pubkey().to_hex(),
|
|
"definition_event_id": binding.definition_event_id().to_hex(),
|
|
"message_event_id": binding.message_event_id().to_hex(),
|
|
"cause": delivery_cause_json(binding.cause()),
|
|
"lease_generation": lease.lease_generation,
|
|
"lease_until": lease.lease_until,
|
|
})
|
|
}
|
|
|
|
fn delivery_cause_json(cause: &WorkflowDeliveryCause) -> Value {
|
|
match cause {
|
|
WorkflowDeliveryCause::Event(event_id) => {
|
|
serde_json::json!({"kind": "event", "event_id": event_id.to_hex()})
|
|
}
|
|
WorkflowDeliveryCause::Schedule {
|
|
scheduled_for_unix_seconds,
|
|
} => serde_json::json!({
|
|
"kind": "schedule",
|
|
"scheduled_for_unix_seconds": scheduled_for_unix_seconds,
|
|
}),
|
|
WorkflowDeliveryCause::Webhook { invocation_id } => {
|
|
serde_json::json!({"kind": "webhook", "invocation_id": invocation_id})
|
|
}
|
|
}
|
|
}
|
|
|
|
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 std::sync::Arc;
|
|
|
|
use super::*;
|
|
use axum::{
|
|
body::{to_bytes, Body},
|
|
http::{header, Request},
|
|
};
|
|
use base64::Engine;
|
|
use buzz_core::workflow_delivery::WorkflowDeliveryBinding;
|
|
use buzz_db::{
|
|
channel::{ChannelType, ChannelVisibility},
|
|
workflow::WorkflowAgentDelivery,
|
|
};
|
|
use nostr::{EventBuilder, Keys, Kind, Tag};
|
|
use sha2::{Digest, Sha256};
|
|
use sqlx::PgPool;
|
|
use tower::ServiceExt;
|
|
|
|
use crate::{router::build_router, state::AppState};
|
|
|
|
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1
|
|
|
|
struct AlwaysFreshReplayGuard;
|
|
|
|
impl buzz_auth::Nip98ReplayGuard for AlwaysFreshReplayGuard {
|
|
fn try_mark_in_scope<'a>(
|
|
&'a self,
|
|
_scope: &'a str,
|
|
_event_id: &'a nostr::EventId,
|
|
_ttl_secs: u64,
|
|
) -> std::pin::Pin<
|
|
Box<dyn std::future::Future<Output = Result<bool, buzz_auth::AuthError>> + Send + 'a>,
|
|
> {
|
|
Box::pin(async { Ok(true) })
|
|
}
|
|
}
|
|
|
|
struct DeliveryRouteFixture {
|
|
state: Arc<AppState>,
|
|
pool: PgPool,
|
|
host: String,
|
|
community: buzz_core::CommunityId,
|
|
target: Keys,
|
|
other_target: Keys,
|
|
binding: WorkflowDeliveryBinding,
|
|
delivery_id: WorkflowDeliveryId,
|
|
}
|
|
|
|
fn nip98_auth_header(keys: &Keys, url: &str, body: &[u8]) -> String {
|
|
let hash: [u8; 32] = Sha256::digest(body).into();
|
|
let tags = vec![
|
|
Tag::parse(["u", url]).expect("u tag"),
|
|
Tag::parse(["method", "POST"]).expect("method tag"),
|
|
Tag::parse(["payload", hex::encode(hash).as_str()]).expect("payload tag"),
|
|
];
|
|
let event = EventBuilder::new(Kind::HttpAuth, "")
|
|
.tags(tags)
|
|
.sign_with_keys(keys)
|
|
.expect("sign NIP-98 event");
|
|
let encoded = base64::engine::general_purpose::STANDARD
|
|
.encode(serde_json::to_vec(&event).expect("serialize NIP-98 event"));
|
|
format!("Nostr {encoded}")
|
|
}
|
|
|
|
async fn post_delivery_json(
|
|
fixture: &DeliveryRouteFixture,
|
|
keys: &Keys,
|
|
path: &str,
|
|
body: Value,
|
|
) -> axum::response::Response {
|
|
let body = body.to_string();
|
|
let url = format!("https://{}{path}", fixture.host);
|
|
let auth = nip98_auth_header(keys, &url, body.as_bytes());
|
|
build_router(Arc::clone(&fixture.state))
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(path)
|
|
.header(header::HOST, &fixture.host)
|
|
.header(header::AUTHORIZATION, auth)
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from(body))
|
|
.expect("request"),
|
|
)
|
|
.await
|
|
.expect("response")
|
|
}
|
|
|
|
async fn response_json(response: axum::response::Response) -> Value {
|
|
let body = to_bytes(response.into_body(), 1024 * 1024)
|
|
.await
|
|
.expect("read response body");
|
|
serde_json::from_slice(&body).expect("response JSON")
|
|
}
|
|
|
|
fn binding_json(binding: &WorkflowDeliveryBinding) -> Value {
|
|
let cause = match binding.cause() {
|
|
WorkflowDeliveryCause::Event(event_id) => {
|
|
serde_json::json!({"kind": "event", "event_id": event_id.to_hex()})
|
|
}
|
|
WorkflowDeliveryCause::Schedule {
|
|
scheduled_for_unix_seconds,
|
|
} => serde_json::json!({
|
|
"kind": "schedule",
|
|
"scheduled_for_unix_seconds": scheduled_for_unix_seconds,
|
|
}),
|
|
WorkflowDeliveryCause::Webhook { invocation_id } => {
|
|
serde_json::json!({"kind": "webhook", "invocation_id": invocation_id})
|
|
}
|
|
};
|
|
serde_json::json!({
|
|
"community_id": binding.community_id().as_uuid(),
|
|
"workflow_id": binding.workflow_id(),
|
|
"run_id": binding.run_id(),
|
|
"step_id": binding.step_id(),
|
|
"target_pubkey": binding.target_pubkey().to_hex(),
|
|
"definition_event_id": binding.definition_event_id().to_hex(),
|
|
"message_event_id": binding.message_event_id().to_hex(),
|
|
"cause": cause,
|
|
})
|
|
}
|
|
|
|
async fn delivery_route_fixture() -> Option<DeliveryRouteFixture> {
|
|
let mut config = crate::config::Config::from_env().ok()?;
|
|
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
|
|
.or_else(|_| std::env::var("DATABASE_URL"))
|
|
.unwrap_or_else(|_| TEST_DB_URL.to_owned());
|
|
config.database_url = database_url.clone();
|
|
config.redis_url = "redis://127.0.0.1:6379".to_owned();
|
|
let host = format!("workflow-delivery-{}.example", Uuid::new_v4().simple());
|
|
config.relay_url = format!("wss://{host}");
|
|
config.require_relay_membership = false;
|
|
|
|
let pool = PgPool::connect(&database_url).await.ok()?;
|
|
let db = buzz_db::Db::from_pool(pool.clone());
|
|
db.migrate().await.ok()?;
|
|
let community = db.ensure_configured_community(&host).await.ok()?.id;
|
|
let redis_pool = deadpool_redis::Config::from_url(&config.redis_url)
|
|
.create_pool(Some(deadpool_redis::Runtime::Tokio1))
|
|
.ok()?;
|
|
let pubsub = Arc::new(
|
|
buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone())
|
|
.await
|
|
.ok()?,
|
|
);
|
|
let audit = buzz_audit::AuditService::new(pool.clone());
|
|
let auth = buzz_auth::AuthService::new(config.auth.clone());
|
|
let search = buzz_search::SearchService::new(pool.clone());
|
|
let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new(
|
|
db.clone(),
|
|
buzz_workflow::WorkflowConfig::default(),
|
|
));
|
|
let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?;
|
|
let (mut state, _audit_shutdown) = AppState::new(
|
|
config,
|
|
db.clone(),
|
|
redis_pool,
|
|
audit,
|
|
pubsub,
|
|
auth,
|
|
search,
|
|
workflow_engine,
|
|
Keys::generate(),
|
|
media_storage,
|
|
);
|
|
state.nip98_replay = Arc::new(AlwaysFreshReplayGuard);
|
|
|
|
let owner = Keys::generate();
|
|
let target = Keys::generate();
|
|
let other_target = Keys::generate();
|
|
for keys in [&owner, &target, &other_target] {
|
|
db.ensure_user(community, keys.public_key().as_bytes())
|
|
.await
|
|
.ok()?;
|
|
}
|
|
db.set_agent_owner(
|
|
community,
|
|
target.public_key().as_bytes(),
|
|
owner.public_key().as_bytes(),
|
|
)
|
|
.await
|
|
.ok()?;
|
|
db.set_agent_owner(
|
|
community,
|
|
other_target.public_key().as_bytes(),
|
|
owner.public_key().as_bytes(),
|
|
)
|
|
.await
|
|
.ok()?;
|
|
let channel = db
|
|
.create_channel(
|
|
community,
|
|
"delivery-route-test",
|
|
ChannelType::Stream,
|
|
ChannelVisibility::Private,
|
|
None,
|
|
owner.public_key().as_bytes(),
|
|
Some(3600),
|
|
)
|
|
.await
|
|
.ok()?;
|
|
let workflow_id = db
|
|
.create_workflow(
|
|
community,
|
|
Some(channel.id),
|
|
owner.public_key().as_bytes(),
|
|
"delivery-route-test",
|
|
r#"{"name":"delivery-route-test","on":{"manual":{}},"steps":[]}"#,
|
|
&[0x44; 32],
|
|
)
|
|
.await
|
|
.ok()?;
|
|
let definition = EventBuilder::new(Kind::Custom(30620), "signed definition")
|
|
.tag(Tag::parse(["d", workflow_id.to_string().as_str()]).ok()?)
|
|
.sign_with_keys(&owner)
|
|
.ok()?;
|
|
let message = EventBuilder::new(Kind::Custom(9), "private execution input")
|
|
.tags([
|
|
Tag::parse(["h", channel.id.to_string().as_str()]).ok()?,
|
|
Tag::parse([
|
|
"p",
|
|
target.public_key().to_hex().as_str(),
|
|
"",
|
|
buzz_core::workflow_delivery::WORKFLOW_DELIVERY_TARGET_MARKER,
|
|
])
|
|
.ok()?,
|
|
])
|
|
.sign_with_keys(&owner)
|
|
.ok()?;
|
|
db.insert_event(community, &definition, Some(channel.id))
|
|
.await
|
|
.ok()?;
|
|
db.insert_event(community, &message, Some(channel.id))
|
|
.await
|
|
.ok()?;
|
|
sqlx::query(
|
|
"UPDATE workflows SET definition_event_id = $3 WHERE community_id = $1 AND id = $2",
|
|
)
|
|
.bind(community.as_uuid())
|
|
.bind(workflow_id)
|
|
.bind(definition.id.as_bytes().as_slice())
|
|
.execute(&pool)
|
|
.await
|
|
.ok()?;
|
|
let run_id = db
|
|
.create_workflow_run(
|
|
community,
|
|
workflow_id,
|
|
definition.id.as_bytes(),
|
|
Some(message.id.as_bytes()),
|
|
None,
|
|
)
|
|
.await
|
|
.ok()?;
|
|
let binding = WorkflowDeliveryBinding::new(
|
|
community,
|
|
workflow_id,
|
|
run_id,
|
|
"notify",
|
|
target.public_key(),
|
|
definition.id,
|
|
message.id,
|
|
WorkflowDeliveryCause::Event(message.id),
|
|
)
|
|
.ok()?;
|
|
let delivery_id = WorkflowDeliveryId::from_uuid(Uuid::new_v4());
|
|
let (transaction, existing) = buzz_db::workflow::lock_workflow_agent_delivery_identity(
|
|
&pool, community, run_id, "notify",
|
|
)
|
|
.await
|
|
.ok()?;
|
|
if existing {
|
|
return None;
|
|
}
|
|
let stored_message = db
|
|
.get_event_by_id(community, message.id.as_bytes())
|
|
.await
|
|
.ok()??;
|
|
buzz_db::workflow::commit_workflow_agent_deliveries(
|
|
transaction,
|
|
community,
|
|
DateTime::<Utc>::from_timestamp(stored_message.event.created_at.as_secs() as i64, 0)
|
|
.expect("message timestamp"),
|
|
&[WorkflowAgentDelivery {
|
|
id: delivery_id,
|
|
binding: binding.clone(),
|
|
}],
|
|
)
|
|
.await
|
|
.ok()?;
|
|
|
|
Some(DeliveryRouteFixture {
|
|
state: Arc::new(state),
|
|
pool,
|
|
host,
|
|
community,
|
|
target,
|
|
other_target,
|
|
binding,
|
|
delivery_id,
|
|
})
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn authenticated_delivery_routes_complete_the_fenced_lifecycle() {
|
|
let Some(fixture) = delivery_route_fixture().await else {
|
|
eprintln!("skipping: Postgres unavailable");
|
|
return;
|
|
};
|
|
let binding = binding_json(&fixture.binding);
|
|
let claim = post_delivery_json(
|
|
&fixture,
|
|
&fixture.target,
|
|
"/workflows/agent-deliveries/claim",
|
|
serde_json::json!({
|
|
"delivery_id": fixture.delivery_id.as_uuid(),
|
|
"expected": binding,
|
|
"lease_seconds": 60,
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(claim.status(), StatusCode::OK);
|
|
let claimed = response_json(claim).await;
|
|
assert_eq!(claimed["delivery"]["lease_generation"], 1);
|
|
assert_eq!(claimed["definition_event"]["content"], "signed definition");
|
|
assert_eq!(
|
|
claimed["message_event"]["content"],
|
|
"private execution input"
|
|
);
|
|
assert!(claimed.get("execution_trace").is_none());
|
|
assert!(claimed.get("trigger_context").is_none());
|
|
assert!(claimed.get("webhook_fields").is_none());
|
|
let receipt: nostr::Event =
|
|
serde_json::from_value(claimed["receipt_event"].clone()).expect("signed receipt event");
|
|
assert_eq!(receipt.pubkey, fixture.state.relay_keypair.public_key());
|
|
assert!(receipt.verify().is_ok());
|
|
assert!(receipt.content.is_empty());
|
|
assert!(!claimed["receipt_event"]
|
|
.to_string()
|
|
.contains("private execution input"));
|
|
|
|
let lease = serde_json::json!({
|
|
"lease_generation": 1,
|
|
"binding": binding_json(&fixture.binding),
|
|
});
|
|
let path = format!("/workflows/agent-deliveries/{}/read", fixture.delivery_id);
|
|
let read = post_delivery_json(&fixture, &fixture.target, &path, lease.clone()).await;
|
|
assert_eq!(read.status(), StatusCode::OK);
|
|
|
|
let path = format!("/workflows/agent-deliveries/{}/renew", fixture.delivery_id);
|
|
let renew = post_delivery_json(
|
|
&fixture,
|
|
&fixture.target,
|
|
&path,
|
|
serde_json::json!({
|
|
"lease_generation": 1,
|
|
"binding": binding_json(&fixture.binding),
|
|
"lease_seconds": 60,
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(renew.status(), StatusCode::OK);
|
|
assert_eq!(response_json(renew).await["renewed"], true);
|
|
|
|
let path = format!("/workflows/agent-deliveries/{}/finish", fixture.delivery_id);
|
|
let finish_body = serde_json::json!({
|
|
"lease_generation": 1,
|
|
"binding": binding_json(&fixture.binding),
|
|
"disposition": "finished",
|
|
});
|
|
let finish =
|
|
post_delivery_json(&fixture, &fixture.target, &path, finish_body.clone()).await;
|
|
assert_eq!(finish.status(), StatusCode::OK);
|
|
assert_eq!(response_json(finish).await["replayed"], false);
|
|
let replay = post_delivery_json(&fixture, &fixture.target, &path, finish_body).await;
|
|
assert_eq!(replay.status(), StatusCode::OK);
|
|
assert_eq!(response_json(replay).await["replayed"], true);
|
|
let conflicting = post_delivery_json(
|
|
&fixture,
|
|
&fixture.target,
|
|
&path,
|
|
serde_json::json!({
|
|
"lease_generation": 1,
|
|
"binding": binding_json(&fixture.binding),
|
|
"disposition": "failed",
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(conflicting.status(), StatusCode::CONFLICT);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn authenticated_delivery_routes_reject_wrong_authority_and_stale_fences() {
|
|
let Some(fixture) = delivery_route_fixture().await else {
|
|
eprintln!("skipping: Postgres unavailable");
|
|
return;
|
|
};
|
|
let claim_path = "/workflows/agent-deliveries/claim";
|
|
let wrong_target = post_delivery_json(
|
|
&fixture,
|
|
&fixture.other_target,
|
|
claim_path,
|
|
serde_json::json!({
|
|
"delivery_id": fixture.delivery_id.as_uuid(),
|
|
"expected": binding_json(&fixture.binding),
|
|
"lease_seconds": 60,
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(wrong_target.status(), StatusCode::NOT_FOUND);
|
|
|
|
let mut wrong_tenant = binding_json(&fixture.binding);
|
|
wrong_tenant["community_id"] = serde_json::json!(Uuid::new_v4());
|
|
let response = post_delivery_json(
|
|
&fixture,
|
|
&fixture.target,
|
|
claim_path,
|
|
serde_json::json!({
|
|
"delivery_id": fixture.delivery_id.as_uuid(),
|
|
"expected": wrong_tenant,
|
|
"lease_seconds": 60,
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
|
|
let mut wrong_binding = binding_json(&fixture.binding);
|
|
wrong_binding["step_id"] = serde_json::json!("other-step");
|
|
let response = post_delivery_json(
|
|
&fixture,
|
|
&fixture.target,
|
|
claim_path,
|
|
serde_json::json!({
|
|
"delivery_id": fixture.delivery_id.as_uuid(),
|
|
"expected": wrong_binding,
|
|
"lease_seconds": 60,
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
|
|
let claim = post_delivery_json(
|
|
&fixture,
|
|
&fixture.target,
|
|
claim_path,
|
|
serde_json::json!({
|
|
"delivery_id": fixture.delivery_id.as_uuid(),
|
|
"expected": binding_json(&fixture.binding),
|
|
"lease_seconds": 60,
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(claim.status(), StatusCode::OK);
|
|
let read_path = format!("/workflows/agent-deliveries/{}/read", fixture.delivery_id);
|
|
let stale_generation = post_delivery_json(
|
|
&fixture,
|
|
&fixture.target,
|
|
&read_path,
|
|
serde_json::json!({
|
|
"lease_generation": 0,
|
|
"binding": binding_json(&fixture.binding),
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(stale_generation.status(), StatusCode::CONFLICT);
|
|
|
|
sqlx::query(
|
|
"UPDATE workflow_agent_deliveries SET lease_until = NOW() - INTERVAL '1 second' WHERE community_id = $1 AND id = $2",
|
|
)
|
|
.bind(fixture.community.as_uuid())
|
|
.bind(fixture.delivery_id.as_uuid())
|
|
.execute(&fixture.pool)
|
|
.await
|
|
.expect("expire delivery lease");
|
|
let expired = post_delivery_json(
|
|
&fixture,
|
|
&fixture.target,
|
|
&read_path,
|
|
serde_json::json!({
|
|
"lease_generation": 1,
|
|
"binding": binding_json(&fixture.binding),
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(expired.status(), StatusCode::CONFLICT);
|
|
}
|
|
|
|
#[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 delivery_wire_excludes_private_run_state() {
|
|
let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4());
|
|
let target = nostr::Keys::generate().public_key();
|
|
let binding = WorkflowDeliveryBinding::new(
|
|
community,
|
|
Uuid::new_v4(),
|
|
Uuid::new_v4(),
|
|
"notify".to_owned(),
|
|
target,
|
|
nostr::EventId::from_byte_array([0x11; 32]),
|
|
nostr::EventId::from_byte_array([0x22; 32]),
|
|
WorkflowDeliveryCause::Webhook {
|
|
invocation_id: Uuid::new_v4(),
|
|
},
|
|
)
|
|
.expect("valid delivery binding");
|
|
let delivery = buzz_db::workflow::WorkflowAgentDeliveryRecord {
|
|
id: WorkflowDeliveryId::from_uuid(Uuid::new_v4()),
|
|
binding,
|
|
status: buzz_db::workflow::WorkflowDeliveryStatus::Claimed,
|
|
lease_generation: 4,
|
|
lease_until: Some(Utc::now()),
|
|
claimed_at: Some(Utc::now()),
|
|
finished_at: None,
|
|
created_at: Utc::now(),
|
|
};
|
|
let lease = buzz_db::workflow::WorkflowDeliveryLease {
|
|
community_id: community,
|
|
delivery_id: delivery.id,
|
|
target_pubkey: target,
|
|
lease_generation: 4,
|
|
lease_until: Utc::now(),
|
|
};
|
|
|
|
let wire = serde_json::json!({
|
|
"delivery": delivery_json(&delivery, &lease),
|
|
"definition_event": {"content": "signed definition"},
|
|
"message_event": {"content": "visible message"},
|
|
});
|
|
assert!(wire.get("execution_trace").is_none());
|
|
assert!(wire.get("trigger_context").is_none());
|
|
assert!(wire.get("webhook_fields").is_none());
|
|
assert_eq!(wire["delivery"]["lease_generation"], 4);
|
|
}
|
|
|
|
#[test]
|
|
fn delivery_binding_rejects_cross_tenant_and_cross_target() {
|
|
let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4());
|
|
let tenant = TenantContext::resolved(community, "agent.example");
|
|
let authenticated = nostr::Keys::generate().public_key();
|
|
let other = nostr::Keys::generate().public_key();
|
|
let request = |community_id, target_pubkey: String| DeliveryBindingRequest {
|
|
community_id,
|
|
workflow_id: Uuid::new_v4(),
|
|
run_id: Uuid::new_v4(),
|
|
step_id: "notify".to_owned(),
|
|
target_pubkey,
|
|
definition_event_id: "11".repeat(32),
|
|
message_event_id: "22".repeat(32),
|
|
cause: DeliveryCauseRequest::Webhook {
|
|
invocation_id: Uuid::new_v4(),
|
|
},
|
|
};
|
|
|
|
let (status, _) = parse_delivery_binding(
|
|
request(Uuid::new_v4(), authenticated.to_hex()),
|
|
&tenant,
|
|
authenticated,
|
|
)
|
|
.expect_err("body tenant cannot override host-resolved tenant");
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
|
|
let (status, _) = parse_delivery_binding(
|
|
request(*community.as_uuid(), other.to_hex()),
|
|
&tenant,
|
|
authenticated,
|
|
)
|
|
.expect_err("authenticated target must match the binding");
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_binding_and_lease_bounds_fail_closed() {
|
|
let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4());
|
|
let tenant = TenantContext::resolved(community, "agent.example");
|
|
let authenticated = nostr::Keys::generate().public_key();
|
|
let malformed = DeliveryBindingRequest {
|
|
community_id: *community.as_uuid(),
|
|
workflow_id: Uuid::new_v4(),
|
|
run_id: Uuid::new_v4(),
|
|
step_id: "notify".to_owned(),
|
|
target_pubkey: authenticated.to_hex(),
|
|
definition_event_id: "not-an-event-id".to_owned(),
|
|
message_event_id: "22".repeat(32),
|
|
cause: DeliveryCauseRequest::Webhook {
|
|
invocation_id: Uuid::new_v4(),
|
|
},
|
|
};
|
|
let (status, _) = parse_delivery_binding(malformed, &tenant, authenticated)
|
|
.expect_err("malformed signed authority must fail closed");
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
assert!(validate_lease_seconds(MIN_DELIVERY_LEASE_SECONDS - 1).is_err());
|
|
assert!(validate_lease_seconds(MAX_DELIVERY_LEASE_SECONDS + 1).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn terminal_replay_requires_the_same_disposition() {
|
|
assert!(terminal_matches(
|
|
buzz_db::workflow::WorkflowDeliveryStatus::Finished,
|
|
DeliveryDisposition::Finished,
|
|
));
|
|
assert!(terminal_matches(
|
|
buzz_db::workflow::WorkflowDeliveryStatus::Failed,
|
|
DeliveryDisposition::Failed,
|
|
));
|
|
assert!(!terminal_matches(
|
|
buzz_db::workflow::WorkflowDeliveryStatus::Finished,
|
|
DeliveryDisposition::Failed,
|
|
));
|
|
assert!(!terminal_matches(
|
|
buzz_db::workflow::WorkflowDeliveryStatus::Failed,
|
|
DeliveryDisposition::Finished,
|
|
));
|
|
}
|
|
|
|
#[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]));
|
|
}
|
|
}
|