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
6 changes: 5 additions & 1 deletion crates/buzz-db/src/workflow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ pub const LIST_MAX_LIMIT: i64 = 1000;
///
/// Approval tokens are stored hashed so that a DB read does not expose
/// the raw token (same pattern as API tokens in buzz-auth).
fn hash_approval_token(token: &str) -> Vec<u8> {
///
/// Public so the relay can derive the same hash when it announces a pending
/// approval (the kind:46010 `d` tag) — keeping every token-hash derivation on
/// this one definition.
pub fn hash_approval_token(token: &str) -> Vec<u8> {
Sha256::digest(token.as_bytes()).to_vec()
}

Expand Down
124 changes: 122 additions & 2 deletions crates/buzz-relay/src/workflow_sink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Weak};

use buzz_core::kind::KIND_STREAM_MESSAGE;
use buzz_core::kind::{KIND_STREAM_MESSAGE, KIND_WORKFLOW_APPROVAL_REQUESTED};
use buzz_core::tenant::CommunityId;
use buzz_workflow::action_sink::{ActionSink, ActionSinkError};
use buzz_workflow::action_sink::{ActionSink, ActionSinkError, ApprovalRequestNotice};
use chrono::Utc;
use nostr::{EventBuilder, Kind, Tag};
use tracing::info;
Expand Down Expand Up @@ -362,6 +362,126 @@ impl ActionSink for RelayActionSink {
Ok(event_id_hex)
})
}

fn emit_approval_requested(
&self,
community_id: CommunityId,
notice: ApprovalRequestNotice,
) -> Pin<Box<dyn Future<Output = Result<String, ActionSinkError>> + Send + '_>> {
Box::pin(async move {
let state = self
.state
.upgrade()
.ok_or_else(|| ActionSinkError::Database("relay is shutting down".into()))?;

// Same tenant-binding rule as send_message: the suspended run
// carries its owning community; never re-derive from config.
let host = state
.db
.lookup_community_host(community_id)
.await
.map_err(|e| ActionSinkError::Database(e.to_string()))?
.ok_or_else(|| {
ActionSinkError::Database(format!(
"workflow run community {community_id} is not mapped to a host"
))
})?;
let tenant = buzz_core::tenant::TenantContext::resolved(community_id, host);

// `d` tag carries the token *hash* — the exact lookup key
// grant/deny handlers use (`get_approval_by_stored_hash`). The
// raw token travels only in the content payload.
let token_hash_hex =
hex::encode(buzz_db::workflow::hash_approval_token(&notice.raw_token));

let content = serde_json::json!({
"message": notice.message,
"token": notice.raw_token,
"workflow_id": notice.workflow_id,
"run_id": notice.run_id,
"step_id": notice.step_id,
"expires_at": notice.expires_at.to_rfc3339(),
})
.to_string();

let mut tags = vec![
Tag::parse(["d", &token_hash_hex])
.map_err(|e| ActionSinkError::EventBuild(format!("d tag: {e}")))?,
Tag::parse(["p", &notice.notify_pubkey_hex])
.map_err(|e| ActionSinkError::EventBuild(format!("p tag: {e}")))?,
Tag::parse(["buzz:workflow", "true"])
.map_err(|e| ActionSinkError::EventBuild(format!("workflow tag: {e}")))?,
];
if let Some(channel_uuid) = notice.channel_id {
tags.push(
Tag::parse(["h", &channel_uuid.to_string()])
.map_err(|e| ActionSinkError::EventBuild(format!("h tag: {e}")))?,
);
}

let kind = Kind::from(KIND_WORKFLOW_APPROVAL_REQUESTED as u16);
let event = EventBuilder::new(kind, &content)
.tags(tags)
.sign_with_keys(&state.relay_keypair)
.map_err(|e| ActionSinkError::EventBuild(format!("signing: {e}")))?;

let event_id_hex = event.id.to_hex();
let event_id_bytes = event.id.as_bytes().to_vec();
let event_created_at = {
let ts = event.created_at.as_secs() as i64;
chrono::DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
};

info!(
event_id = %event_id_hex,
run_id = %notice.run_id,
step_id = %notice.step_id,
"Workflow RequestApproval: announcing kind {KIND_WORKFLOW_APPROVAL_REQUESTED} event"
);

// Approval announcements are top-level events; give them thread
// metadata only when channel-scoped (same shape as send_message).
let thread_meta =
notice
.channel_id
.map(|channel_uuid| buzz_db::event::ThreadMetadataParams {
event_id: &event_id_bytes,
event_created_at,
channel_id: channel_uuid,
parent_event_id: None,
parent_event_created_at: None,
root_event_id: None,
root_event_created_at: None,
depth: 0,
broadcast: false,
});

let (stored_event, was_inserted) = state
.db
.insert_event_with_thread_metadata(
tenant.community(),
&event,
notice.channel_id,
thread_meta,
)
.await
.map_err(|e| ActionSinkError::Database(e.to_string()))?;

if was_inserted {
let _ = dispatch_persistent_event(
&tenant,
&state,
&stored_event,
KIND_WORKFLOW_APPROVAL_REQUESTED,
&notice.notify_pubkey_hex,
None,
)
.await;
}

Ok(event_id_hex)
})
}
}

#[cfg(test)]
Expand Down
40 changes: 40 additions & 0 deletions crates/buzz-workflow/src/action_sink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,44 @@ pub trait ActionSink: Send + Sync {
text: &str,
author_pubkey: &str,
) -> Pin<Box<dyn Future<Output = Result<String, ActionSinkError>> + Send + '_>>;

/// Announce a pending approval as a kind:46010 event (WF-08).
///
/// The event carries the raw token in its JSON content (approvers hash it
/// into the grant's `d` tag via `buzz workflows approve --token …`), a
/// `d` tag holding the SHA-256 token hash so relay handlers and clients
/// can correlate it with grant/deny events, and a `p` tag for the notify
/// pubkey so the request surfaces in that user's needs-action feed.
///
/// Returns the event ID hex string on success.
fn emit_approval_requested(
&self,
community_id: CommunityId,
notice: ApprovalRequestNotice,
) -> Pin<Box<dyn Future<Output = Result<String, ActionSinkError>> + Send + '_>>;
}

/// Everything the sink needs to announce a pending approval (kind:46010).
#[derive(Debug, Clone)]
pub struct ApprovalRequestNotice {
/// Channel to post into (the workflow's channel), if the workflow is
/// channel-scoped. `None` emits an unscoped event that still reaches the
/// notify pubkey's feed.
pub channel_id: Option<uuid::Uuid>,
/// Raw approval token — never persisted raw; delivered so approvers can
/// present it back to the grant endpoint.
pub raw_token: String,
/// Template-resolved, human-facing approval message.
pub message: String,
/// Pubkey (hex) to `p`-tag: the resolved approver, or the workflow owner
/// when the spec is `"any"`.
pub notify_pubkey_hex: String,
/// The workflow the suspended run belongs to.
pub workflow_id: uuid::Uuid,
/// The suspended run awaiting this approval.
pub run_id: uuid::Uuid,
/// The step that requested approval.
pub step_id: String,
/// When the approval window closes.
pub expires_at: chrono::DateTime<chrono::Utc>,
}
136 changes: 119 additions & 17 deletions crates/buzz-workflow/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -451,16 +451,34 @@ pub fn resolve_step_templates(
}
}

/// Suspend metadata carried out of the run loop when a `RequestApproval`
/// step suspends execution (WF-08): everything the engine needs to persist
/// the approval record and announce it — the raw token, the requesting step,
/// the resolved approver, and the human-facing message.
#[derive(Debug, Clone)]
pub struct PendingApproval {
/// Raw approval token. The DB layer hashes it at persist time; it is
/// carried raw here so the kind:46010 announcement can deliver it to
/// approvers (who hash it back into the grant's `d` tag).
pub token: String,
/// The step that requested approval.
pub step_id: String,
/// Approver spec in grant-check form: `"any"` or a 64-char hex pubkey.
pub approver_spec: String,
/// Template-resolved, human-facing approval message.
pub message: String,
/// Approval window in seconds (from `timeout:`, default 24h).
pub timeout_secs: u64,
}

/// Result of dispatching a single step action.
#[derive(Debug)]
pub enum StepResult {
/// Step completed normally. Output is stored in `step_outputs`.
Completed(JsonValue),
/// Step requests suspension (approval gate). Execution must pause.
Suspended {
/// Token used to resume or reject this approval gate.
approval_token: String,
},
/// Step requests suspension (approval gate). Execution must pause and the
/// caller must persist the approval so a later grant can resume the run.
Suspended(PendingApproval),
/// Step was skipped due to `if:` condition being false.
Skipped,
}
Expand Down Expand Up @@ -653,19 +671,28 @@ pub async fn dispatch_action(
timeout,
} => {
let timeout_str = timeout.as_deref().unwrap_or("24h");
let timeout_secs = parse_duration_secs(timeout_str)?;
// Resolve the approver before any state is persisted: the relay's
// grant authorization (`check_approver_spec`) accepts only "any"
// or a hex pubkey, so an unresolvable spec would mint an approval
// no grant can ever satisfy. Fail the step loudly instead of
// suspending into a dead end.
let approver_spec =
resolve_approver_spec(from).map_err(WorkflowError::InvalidDefinition)?;
info!(
run_id = %run_id, step = step_id,
"RequestApproval from={from} timeout={timeout_str}: {message}"
);

let token = generate_approval_token(run_id, step_id);

// TODO (WF-08): create approval record in DB, emit kind:46010.
// For now, return Suspended with the token so the caller can persist state.

Ok(StepResult::Suspended {
approval_token: token,
})
Ok(StepResult::Suspended(PendingApproval {
token,
step_id: step_id.to_string(),
approver_spec,
message: message.clone(),
timeout_secs,
}))
}

Delay { duration } => {
Expand Down Expand Up @@ -699,6 +726,36 @@ fn generate_approval_token(_run_id: Uuid, _step_id: &str) -> String {
Uuid::new_v4().to_string()
}

/// Resolve a `request_approval.from` spec into the form the relay's grant
/// authorization (`check_approver_spec`) understands: `"any"` or a lowercase
/// 64-char hex pubkey.
///
/// Accepted inputs: `""`/`"any"` (case-insensitive), a hex pubkey, an
/// `npub1…` bech32 pubkey, or any of those with a leading `@`. Display-name
/// mentions (e.g. `@release-manager`) cannot be resolved here — the engine
/// has no name directory at suspend time — and are rejected so the run fails
/// visibly instead of minting an approval nobody can ever grant.
pub(crate) fn resolve_approver_spec(from: &str) -> Result<String, String> {
let spec = from.trim();
if spec.is_empty() || spec.eq_ignore_ascii_case("any") {
return Ok("any".to_string());
}
let bare = spec.strip_prefix('@').unwrap_or(spec);
if bare.len() == 64 && bare.bytes().all(|b| b.is_ascii_hexdigit()) {
return Ok(bare.to_ascii_lowercase());
}
if bare.starts_with("npub1") {
use nostr::prelude::FromBech32;
if let Ok(pk) = nostr::PublicKey::from_bech32(bare) {
return Ok(pk.to_hex());
}
}
Err(format!(
"request_approval.from '{from}' cannot be resolved to an approver — \
use \"any\", a 64-char hex pubkey, or an npub1… key"
))
}

/// Parse a duration string like "5m", "1h", "30s" into seconds.
///
/// Exposed as `pub(crate)` so `schema.rs` can use it for interval validation.
Expand Down Expand Up @@ -939,7 +996,7 @@ async fn add_reaction_impl(message_id: &str, emoji: &str) -> Result<JsonValue, W
pub struct ExecutionResult {
/// Set when execution suspended at a `RequestApproval` step.
/// `None` means the run completed normally.
pub approval_token: Option<String>,
pub approval: Option<PendingApproval>,
/// Index of the step that suspended (or the total step count on completion).
pub step_index: usize,
/// Accumulated step outputs at the point of suspension or completion.
Expand Down Expand Up @@ -1180,15 +1237,15 @@ async fn execute_steps(
}));
step_outputs.insert(step.id.clone(), output);
}
StepResult::Suspended { approval_token } => {
StepResult::Suspended(pending) => {
info!(
run_id = %run_id, step = %step.id,
"Step suspended — awaiting approval (token: <redacted>)"
);
// Return the token and current state so the caller can persist the
// approval record and update the run's execution trace.
// Return the pending approval and current state so the caller
// can persist the approval record and update the run status.
return Ok(ExecutionResult {
approval_token: Some(approval_token),
approval: Some(pending),
step_index: i,
step_outputs,
trace,
Expand All @@ -1206,7 +1263,7 @@ async fn execute_steps(

info!(run_id = %run_id, "Workflow run completed");
Ok(ExecutionResult {
approval_token: None,
approval: None,
step_index: def.steps.len(),
step_outputs,
trace,
Expand Down Expand Up @@ -1831,4 +1888,49 @@ mod tests {
.expect("override should be accepted");
assert_eq!(resolved, override_channel_id.to_string());
}

#[test]
fn approver_spec_any_and_empty_resolve_to_any() {
assert_eq!(resolve_approver_spec("").unwrap(), "any");
assert_eq!(resolve_approver_spec(" ").unwrap(), "any");
assert_eq!(resolve_approver_spec("any").unwrap(), "any");
assert_eq!(resolve_approver_spec("ANY").unwrap(), "any");
}

#[test]
fn approver_spec_hex_pubkey_resolves_lowercased() {
let hex_upper: String = "AB".repeat(32);
let hex_lower = hex_upper.to_ascii_lowercase();
assert_eq!(resolve_approver_spec(&hex_upper).unwrap(), hex_lower);
// Leading @ is stripped.
assert_eq!(
resolve_approver_spec(&format!("@{hex_upper}")).unwrap(),
hex_lower
);
}

#[test]
fn approver_spec_npub_resolves_to_hex() {
use nostr::prelude::ToBech32;
let keys = nostr::Keys::generate();
let npub = keys.public_key().to_bech32().expect("bech32 encode");
let expected = keys.public_key().to_hex();
assert_eq!(resolve_approver_spec(&npub).unwrap(), expected);
assert_eq!(
resolve_approver_spec(&format!("@{npub}")).unwrap(),
expected
);
}

#[test]
fn approver_spec_display_names_fail_closed() {
// Display-name mentions can't be resolved to a pubkey at suspend
// time; an approval minted for them could never be granted
// (check_approver_spec accepts only "any"/hex), so they must error.
assert!(resolve_approver_spec("@release-manager").is_err());
assert!(resolve_approver_spec("release-manager").is_err());
// Truncated hex and malformed npub fail too.
assert!(resolve_approver_spec(&"ab".repeat(16)).is_err());
assert!(resolve_approver_spec("npub1notarealkey").is_err());
}
}
Loading