//! Command executor — transactional event processing for command kinds. //! //! Command kinds (41010–41012, 30620, 46020, 46030–46031) are processed //! transactionally: validate → begin tx → insert event → execute mutations → commit. //! //! SECURITY: This module is only reachable AFTER the ingest pipeline has verified: //! 1. Event signature (verify_event) //! 2. Timestamp freshness (±15 min) //! 3. Pubkey/auth identity match //! 4. Per-kind scope authorization use std::sync::Arc; use chrono::Utc; use nostr::Event; use sha2::{Digest, Sha256}; use tracing::warn; use uuid::Uuid; use buzz_core::kind::*; use buzz_core::tenant::{CommunityId, TenantContext}; use buzz_db::workflow::{ApprovalStatus, RunStatus}; use buzz_db::DbError; use buzz_workflow::executor::TriggerContext; use crate::state::AppState; use crate::webhook_secret; use super::ingest::{extract_channel_id, IngestAuth, IngestError, IngestResult}; use super::side_effects::{ emit_group_discovery_events, emit_membership_notification, emit_system_message, publish_dm_visibility_snapshot, }; /// Route a command-kind event to the appropriate handler. pub async fn handle_command( tenant: &TenantContext, state: &Arc, event: Event, auth: IngestAuth, ) -> Result { // Ensure the authenticated user exists in the users table (foreign key requirement). // The old REST handlers did this via extract_auth_context; command executor must do it explicitly. let pubkey_bytes = auth.pubkey().to_bytes().to_vec(); match state .db .ensure_user(tenant.community(), &pubkey_bytes) .await { Ok(true) => { metrics::counter!( "buzz_users_created_total", "community" => tenant.host().to_owned() ) .increment(1); } Ok(false) => {} Err(e) => { tracing::warn!("command_executor: ensure_user failed: {e}"); } } let kind = event.kind.as_u16() as u32; match kind { KIND_DM_OPEN => handle_dm_open(tenant, state, &event, &auth).await, KIND_DM_ADD_MEMBER => handle_dm_add_member(tenant, state, &event, &auth).await, KIND_DM_HIDE => handle_dm_hide(tenant, state, &event, &auth).await, KIND_WORKFLOW_DEF => handle_workflow_def(tenant, state, &event, &auth).await, KIND_WORKFLOW_TRIGGER => handle_workflow_trigger(tenant, state, &event, &auth).await, KIND_APPROVAL_GRANT => handle_approval_grant(tenant, state, &event, &auth).await, KIND_APPROVAL_DENY => handle_approval_deny(tenant, state, &event, &auth).await, _ => Err(IngestError::Rejected(format!( "unknown command kind: {kind}" ))), } } /// Result of persisting a command event: either a duplicate (already processed) /// or an open transaction that the handler must commit after executing mutations. enum PersistResult { /// Event was already processed — return idempotent success. Duplicate, /// Event inserted — transaction is open, handler must commit after mutations. Inserted(sqlx::Transaction<'static, sqlx::Postgres>), } /// Persist a command event inside a transaction. Returns the OPEN transaction /// as an idempotency guard — if the event was already stored, `Duplicate` is /// returned and the handler skips execution. /// /// If the event is a duplicate (ON CONFLICT DO NOTHING), the transaction is /// rolled back and `PersistResult::Duplicate` is returned — no mutations needed. /// /// NOTE: Domain mutations (open_dm, upsert_workflow, etc.) execute on the /// connection pool, NOT inside this transaction. The pattern is idempotent but /// not strictly atomic: if a mutation succeeds but commit fails, the mutation /// persists without the event record. On retry, the event INSERT succeeds /// (no conflict), and the mutation re-executes — which is safe for idempotent /// operations (open_dm, hide_dm, update_approval, upsert_workflow). async fn persist_command_event( state: &Arc, tenant: &TenantContext, event: &Event, channel_id_override: Option, ) -> Result { let channel_id = channel_id_override.or_else(|| extract_channel_id(event)); let mut tx = state .db .begin_transaction() .await .map_err(|e| IngestError::Internal(format!("error: begin transaction: {e}")))?; // INSERT with ON CONFLICT DO NOTHING — idempotency guard. let id_bytes = event.id.as_bytes(); let pubkey_bytes = event.pubkey.to_bytes(); let sig_bytes = event.sig.serialize(); let tags_json = serde_json::to_value(&event.tags) .map_err(|e| IngestError::Internal(format!("error: serialize tags: {e}")))?; let kind_i32 = event.kind.as_u16() as i32; let created_at_secs = event.created_at.as_secs() as i64; let created_at = chrono::DateTime::from_timestamp(created_at_secs, 0).ok_or_else(|| { IngestError::Rejected(format!("invalid: bad timestamp {created_at_secs}")) })?; let received_at = chrono::Utc::now(); // Extract d_tag for parameterized replaceable kinds (NIP-33). let d_tag = buzz_db::event::extract_d_tag(event); if let Some(ref d_tag) = d_tag { if d_tag.len() > buzz_db::event::D_TAG_MAX_LEN { return Err(IngestError::Rejected(format!( "invalid: d tag too long ({} bytes, max {})", d_tag.len(), buzz_db::event::D_TAG_MAX_LEN, ))); } // Command kinds normally use plain insert semantics, but workflow // definitions are NIP-33 events. Serialize writers for the same // coordinate and reject stale writes before executing the domain // mutation, otherwise old updates can overwrite newer workflow state. let lock_key = { let mut h: u64 = 0xcbf29ce484222325; for b in tenant.community().as_uuid().as_bytes() { h ^= *b as u64; h = h.wrapping_mul(0x100000001b3); } for b in kind_i32.to_le_bytes() { h ^= b as u64; h = h.wrapping_mul(0x100000001b3); } for b in pubkey_bytes.as_slice() { h ^= *b as u64; h = h.wrapping_mul(0x100000001b3); } for b in d_tag.as_bytes() { h ^= *b as u64; h = h.wrapping_mul(0x100000001b3); } h as i64 }; sqlx::query("SELECT pg_advisory_xact_lock($1)") .bind(lock_key) .execute(tx.as_mut()) .await .map_err(|e| IngestError::Internal(format!("error: lock event coordinate: {e}")))?; let existing: Option<(chrono::DateTime, Vec)> = sqlx::query_as( "SELECT created_at, id FROM events \ WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL \ ORDER BY created_at DESC, id ASC LIMIT 1", ) .bind(tenant.community().as_uuid()) .bind(kind_i32) .bind(pubkey_bytes.as_slice()) .bind(d_tag) .fetch_optional(tx.as_mut()) .await .map_err(|e| IngestError::Internal(format!("error: query event coordinate: {e}")))?; let incoming_id = event.id.as_bytes().as_slice(); if let Some((existing_ts, existing_id)) = existing { let dominated = created_at < existing_ts || (created_at == existing_ts && incoming_id >= existing_id.as_slice()); if dominated { return Ok(PersistResult::Duplicate); } sqlx::query( "UPDATE events SET deleted_at = NOW() \ WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL", ) .bind(tenant.community().as_uuid()) .bind(kind_i32) .bind(pubkey_bytes.as_slice()) .bind(d_tag) .execute(tx.as_mut()) .await .map_err(|e| IngestError::Internal(format!("error: replace old event: {e}")))?; } } let result = sqlx::query( r#" INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT DO NOTHING "#, ) .bind(tenant.community().as_uuid()) .bind(id_bytes.as_slice()) .bind(pubkey_bytes.as_slice()) .bind(created_at) .bind(kind_i32) .bind(&tags_json) .bind(&event.content) .bind(sig_bytes.as_slice()) .bind(received_at) .bind(channel_id) .bind(d_tag.as_deref()) .execute(tx.as_mut()) .await .map_err(|e| IngestError::Internal(format!("error: insert event: {e}")))?; if result.rows_affected() == 0 { // Duplicate — rollback (implicit on drop) and signal idempotent success. Ok(PersistResult::Duplicate) } else { Ok(PersistResult::Inserted(tx)) } } /// Extract all `p` tag values (hex pubkeys) from an event. fn extract_p_tags(event: &Event) -> Vec { event .tags .iter() .filter_map(|t| { if t.kind().to_string() == "p" { t.content().map(|s| s.to_string()) } else { None } }) .collect() } /// Extract the first `h` tag value (channel UUID) from an event. fn extract_h_tag(event: &Event) -> Option { event.tags.iter().find_map(|t| { if t.kind().to_string() == "h" { t.content().map(|s| s.to_string()) } else { None } }) } /// Extract the first `d` tag value from an event. fn extract_d_tag(event: &Event) -> Option { event.tags.iter().find_map(|t| { if t.kind().to_string() == "d" { t.content().map(|s| s.to_string()) } else { None } }) } /// Extract the first `e` tag value from an event. fn extract_e_tag(event: &Event) -> Option { event.tags.iter().find_map(|t| { if t.kind().to_string() == "e" { t.content().map(|s| s.to_string()) } else { None } }) } /// Extract a tag value by name. fn extract_tag(event: &Event, tag_name: &str) -> Option { event.tags.iter().find_map(|t| { if t.kind().to_string() == tag_name { t.content().map(|s| s.to_string()) } else { None } }) } /// Decode a hex pubkey string to 32 bytes. fn decode_pubkey(hex_str: &str) -> Result, IngestError> { let bytes = hex::decode(hex_str) .map_err(|_| IngestError::Rejected(format!("invalid: bad pubkey hex: {hex_str}")))?; if bytes.len() != 32 { return Err(IngestError::Rejected(format!( "invalid: pubkey must be 32 bytes: {hex_str}" ))); } Ok(bytes) } /// Compute SHA-256 hash of a string, returning raw bytes. fn compute_definition_hash(json_str: &str) -> Vec { Sha256::digest(json_str.as_bytes()).to_vec() } async fn handle_dm_open( tenant: &TenantContext, state: &Arc, event: &Event, auth: &IngestAuth, ) -> Result { let self_bytes = auth.pubkey().to_bytes().to_vec(); let self_hex = hex::encode(&self_bytes); // 1. Extract participant pubkeys from `p` tags let p_tags = extract_p_tags(event); // 2. Validate: at least 1 other participant, max 8 others (9 total) if p_tags.is_empty() { return Err(IngestError::Rejected( "invalid: pubkeys must contain at least 1 other participant".into(), )); } if p_tags.len() > 8 { return Err(IngestError::Rejected( "invalid: pubkeys may contain at most 8 other participants (9 total)".into(), )); } // Decode all provided pubkeys let mut other_bytes: Vec> = Vec::with_capacity(p_tags.len()); for hex_str in &p_tags { other_bytes.push(decode_pubkey(hex_str)?); } // 3. Build full participant set (self + others, deduplicated) let mut all_bytes: Vec> = vec![self_bytes.clone()]; for ob in &other_bytes { if !all_bytes.iter().any(|b| b == ob) { all_bytes.push(ob.clone()); } } // Persist the command event (idempotency) — returns open transaction let tx = match persist_command_event(state, tenant, event, None).await? { PersistResult::Duplicate => { return Ok(IngestResult { event_id: event.id.to_hex(), accepted: true, message: "duplicate: already processed".into(), }); } PersistResult::Inserted(tx) => tx, }; // 4. Execute: open_dm let all_refs: Vec<&[u8]> = all_bytes.iter().map(|b| b.as_slice()).collect(); let (channel, was_created) = state .db .open_dm(tenant.community(), &all_refs, &self_bytes) .await .map_err(|e| IngestError::Internal(format!("error: db open_dm: {e}")))?; // Commit: event + mutation succeeded atomically. tx.commit() .await .map_err(|e| IngestError::Internal(format!("error: commit transaction: {e}")))?; // 5. Side effects if newly created (post-commit, best-effort) if was_created { metrics::counter!( "buzz_channels_created_total", "community" => tenant.host().to_owned(), "type" => "dm" ) .increment(1); // Invalidate caches for all participants for pk in &all_bytes { state.invalidate_membership(tenant, channel.id, pk); } let participant_hexes: Vec = all_bytes.iter().map(hex::encode).collect(); if let Err(e) = emit_system_message( tenant, state, channel.id, serde_json::json!({ "type": "dm_created", "actor": self_hex, "participants": participant_hexes, }), ) .await { warn!("DM open: system message failed: {e}"); } if let Err(e) = emit_group_discovery_events(tenant, state, channel.id).await { warn!(channel = %channel.id, "DM open: discovery emission failed: {e}"); } for participant in &all_bytes { if let Err(e) = emit_membership_notification( tenant, state, channel.id, participant, &self_bytes, KIND_MEMBER_ADDED_NOTIFICATION, ) .await { warn!("DM open: membership notification failed: {e}"); } } } else { // Re-open of an existing DM cleared the caller's hidden_at; refresh // their NIP-DV snapshot so the DM reappears in the sidebar. if let Err(e) = publish_dm_visibility_snapshot(tenant, state, &self_bytes).await { warn!("DM re-open: visibility snapshot failed: {e}"); } } // 6. Return response Ok(IngestResult { event_id: event.id.to_hex(), accepted: true, message: format!( "response:{}", serde_json::json!({ "channel_id": channel.id.to_string(), "created": was_created, }) ), }) } async fn handle_dm_add_member( tenant: &TenantContext, state: &Arc, event: &Event, auth: &IngestAuth, ) -> Result { let self_bytes = auth.pubkey().to_bytes().to_vec(); // 1. Extract target channel from `h` tag, new member pubkeys from `p` tags let channel_id_str = extract_h_tag(event) .ok_or_else(|| IngestError::Rejected("invalid: missing h tag (channel_id)".into()))?; let channel_id = Uuid::parse_str(&channel_id_str) .map_err(|_| IngestError::Rejected("invalid: bad channel_id format".into()))?; let p_tags = extract_p_tags(event); if p_tags.is_empty() { return Err(IngestError::Rejected( "invalid: must specify at least 1 new participant in p tags".into(), )); } // 2. Validate caller is member of existing DM let is_member = state .is_member_cached(tenant.community(), channel_id, &self_bytes) .await .map_err(|e| IngestError::Internal(format!("error: membership check: {e}")))?; if !is_member { return Err(IngestError::Rejected( "forbidden: not a member of this DM".into(), )); } // 3. Validate channel is type "dm" let existing_channel = state .db .get_channel(tenant.community(), channel_id) .await .map_err(|_| IngestError::Rejected("invalid: DM not found".into()))?; if existing_channel.channel_type != "dm" { return Err(IngestError::Rejected("invalid: channel is not a DM".into())); } // 4. Get existing members, merge with new let existing_members = state .db .get_members(tenant.community(), channel_id) .await .map_err(|e| IngestError::Internal(format!("error: get members: {e}")))?; let mut all_bytes: Vec> = existing_members.into_iter().map(|m| m.pubkey).collect(); // Decode and merge new pubkeys for hex_str in &p_tags { let bytes = decode_pubkey(hex_str)?; if !all_bytes.iter().any(|b| b == &bytes) { all_bytes.push(bytes); } } // 5. Enforce max 9 participants if all_bytes.len() > 9 { return Err(IngestError::Rejected( "invalid: DM supports at most 9 participants".into(), )); } // Persist the command event — returns open transaction let tx = match persist_command_event(state, tenant, event, None).await? { PersistResult::Duplicate => { return Ok(IngestResult { event_id: event.id.to_hex(), accepted: true, message: "duplicate: already processed".into(), }); } PersistResult::Inserted(tx) => tx, }; // 6. Execute: open_dm with expanded set (creates NEW DM — DM sets are immutable) let all_refs: Vec<&[u8]> = all_bytes.iter().map(|b| b.as_slice()).collect(); let (new_channel, was_created) = state .db .open_dm(tenant.community(), &all_refs, &self_bytes) .await .map_err(|e| IngestError::Internal(format!("error: db open_dm: {e}")))?; // Commit: event + mutation succeeded atomically. tx.commit() .await .map_err(|e| IngestError::Internal(format!("error: commit transaction: {e}")))?; // 7. Cache invalidation + notifications for new DM (post-commit, best-effort) if was_created { metrics::counter!( "buzz_channels_created_total", "community" => tenant.host().to_owned(), "type" => "dm" ) .increment(1); for pk in &all_bytes { state.invalidate_membership(tenant, new_channel.id, pk); } if let Err(e) = emit_group_discovery_events(tenant, state, new_channel.id).await { warn!(channel = %new_channel.id, "DM add_member: discovery emission failed: {e}"); } for participant_bytes in &all_bytes { if let Err(e) = emit_membership_notification( tenant, state, new_channel.id, participant_bytes, &self_bytes, KIND_MEMBER_ADDED_NOTIFICATION, ) .await { warn!("DM add_member: membership notification failed: {e}"); } } } // 8. Return response Ok(IngestResult { event_id: event.id.to_hex(), accepted: true, message: format!( "response:{}", serde_json::json!({ "channel_id": new_channel.id.to_string(), }) ), }) } async fn handle_dm_hide( tenant: &TenantContext, state: &Arc, event: &Event, auth: &IngestAuth, ) -> Result { let self_bytes = auth.pubkey().to_bytes().to_vec(); // 1. Extract channel from `h` tag let channel_id_str = extract_h_tag(event) .ok_or_else(|| IngestError::Rejected("invalid: missing h tag (channel_id)".into()))?; let channel_id = Uuid::parse_str(&channel_id_str) .map_err(|_| IngestError::Rejected("invalid: bad channel_id format".into()))?; // 2. Validate caller is member of the DM let is_member = state .is_member_cached(tenant.community(), channel_id, &self_bytes) .await .map_err(|e| IngestError::Internal(format!("error: membership check: {e}")))?; if !is_member { return Err(IngestError::Rejected( "forbidden: not a member of this DM".into(), )); } // 3. Validate channel is type "dm" let channel = state .db .get_channel(tenant.community(), channel_id) .await .map_err(|_| IngestError::Rejected("invalid: DM not found".into()))?; if channel.channel_type != "dm" { return Err(IngestError::Rejected("invalid: channel is not a DM".into())); } // Persist the command event — returns open transaction let tx = match persist_command_event(state, tenant, event, None).await? { PersistResult::Duplicate => { return Ok(IngestResult { event_id: event.id.to_hex(), accepted: true, message: "duplicate: already processed".into(), }); } PersistResult::Inserted(tx) => tx, }; // 4. Execute: hide_dm state .db .hide_dm(tenant.community(), channel_id, &self_bytes) .await .map_err(|e| IngestError::Internal(format!("error: db hide_dm: {e}")))?; // Commit: event + mutation succeeded atomically. tx.commit() .await .map_err(|e| IngestError::Internal(format!("error: commit transaction: {e}")))?; // 5. Side effect (post-commit, best-effort): refresh the caller's NIP-DV // visibility snapshot so clients can filter this DM out of the sidebar. if let Err(e) = publish_dm_visibility_snapshot(tenant, state, &self_bytes).await { warn!("DM hide: visibility snapshot failed: {e}"); } // 6. Return response Ok(IngestResult { event_id: event.id.to_hex(), accepted: true, message: "{}".into(), }) } async fn handle_workflow_def( tenant: &TenantContext, state: &Arc, event: &Event, auth: &IngestAuth, ) -> Result { let self_bytes = auth.pubkey().to_bytes().to_vec(); // 1. Extract channel and the canonical workflow UUID from the NIP-33 d-tag. let channel_id_str = extract_h_tag(event) .ok_or_else(|| IngestError::Rejected("invalid: missing h tag (channel_id)".into()))?; let channel_id = Uuid::parse_str(&channel_id_str) .map_err(|_| IngestError::Rejected("invalid: bad channel_id format".into()))?; let workflow_id_str = extract_d_tag(event) .ok_or_else(|| IngestError::Rejected("invalid: missing d tag (workflow_id)".into()))?; let workflow_id = Uuid::parse_str(&workflow_id_str) .map_err(|_| IngestError::Rejected("invalid: bad workflow_id format".into()))?; // 2. Validate caller has channel access (minimum: is a member) let is_member = state .is_member_cached(tenant.community(), channel_id, &self_bytes) .await .map_err(|e| IngestError::Internal(format!("error: membership check: {e}")))?; if !is_member { return Err(IngestError::Rejected( "forbidden: not a member of this channel".into(), )); } // 3. Parse YAML from event.content let (def, definition_json_str) = buzz_workflow::WorkflowEngine::parse_yaml(&event.content) .map_err(|e| IngestError::Rejected(format!("invalid: workflow YAML parse error: {e}")))?; let workflow_name = extract_tag(event, "name").unwrap_or_else(|| def.name.clone()); // SEC-006: definitions with exfiltration-capable actions (call_webhook) // require elevated channel authority to save — plain membership is not // enough, because the workflow will forward channel content outward with // the owner's standing authority. Fail-closed on lookup errors. if def.requires_elevated_authority() { let role = state .db .get_member_role(tenant.community(), channel_id, &self_bytes) .await .map_err(|e| IngestError::Internal(format!("error: role check: {e}")))?; if !matches!(role.as_deref(), Some("owner") | Some("admin")) { return Err(IngestError::Rejected( "forbidden: workflows with call_webhook actions require the owner or admin role" .into(), )); } } let mut definition_json: serde_json::Value = serde_json::from_str(&definition_json_str) .map_err(|e| IngestError::Internal(format!("error: json parse of definition: {e}")))?; let existing_workflow = match state.db.get_workflow(tenant.community(), workflow_id).await { Ok(workflow) => { if workflow.owner_pubkey != self_bytes || workflow.channel_id != Some(channel_id) { return Err(IngestError::Rejected( "forbidden: workflow belongs to a different owner or channel".into(), )); } Some(workflow) } Err(DbError::NotFound(_)) => None, Err(e) => { return Err(IngestError::Internal(format!( "error: db get_workflow: {e}" ))); } }; // Preserve the existing webhook secret across updates. A new secret is // returned only when the workflow first gains a webhook trigger. let webhook_secret = if matches!(def.trigger, buzz_workflow::TriggerDef::Webhook) { let existing_secret = existing_workflow .as_ref() .and_then(|workflow| webhook_secret::extract_secret(&workflow.definition)); let secret = existing_secret.unwrap_or_else(webhook_secret::generate_webhook_secret); webhook_secret::inject_secret(&mut definition_json, &secret); if existing_workflow .as_ref() .and_then(|workflow| webhook_secret::extract_secret(&workflow.definition)) .is_none() { Some(secret) } else { None } } else { None }; // Compute hash AFTER secret injection let definition_json_final = serde_json::to_string(&definition_json) .map_err(|e| IngestError::Internal(format!("error: json serialize: {e}")))?; let hash = compute_definition_hash(&definition_json_final); // Persist the command event — returns open transaction let tx = match persist_command_event(state, tenant, event, None).await? { PersistResult::Duplicate => { return Ok(IngestResult { event_id: event.id.to_hex(), accepted: true, message: "duplicate: already processed".into(), }); } PersistResult::Inserted(tx) => tx, }; // 4. Execute: upsert by the NIP-33 d-tag UUID. A retry updates the same // row instead of creating another enabled workflow that would fan out on // every matching event. The workflow's community is the request's // server-bound tenant — never re-derived from the (client-supplied) channel // id. `community_of_channel(channel_id)` is ambiguous when the same channel // UUID exists in two communities and could mint the workflow under the wrong // tenant; `tenant.community()` is the authoritative owner. We then verify the // channel actually exists *inside that community* (scoped `get_channel`), // which fails closed if the client named a channel that belongs to a // different community — the same guarantee the `(community_id, channel_id)` // composite FK enforces on insert, surfaced here as a clean rejection. let community_id = tenant.community(); state .db .get_channel(community_id, channel_id) .await .map_err(|_| IngestError::Rejected("invalid: workflow channel not found".into()))?; state .db .upsert_workflow( community_id, workflow_id, Some(channel_id), &self_bytes, &workflow_name, &definition_json_final, &hash, ) .await .map_err(|e| match e { DbError::AccessDenied(_) => IngestError::Rejected( "forbidden: workflow belongs to a different owner or channel".into(), ), other => IngestError::Internal(format!("error: db upsert_workflow: {other}")), })?; // Drop the trigger-path cache entry so the new/updated definition fires on // the next matching event instead of after the cache TTL. state .workflow_engine .invalidate_channel_workflows(community_id, channel_id); // Commit the event transaction after the idempotent workflow upsert succeeds. tx.commit() .await .map_err(|e| IngestError::Internal(format!("error: commit transaction: {e}")))?; // 5. Return response let mut resp = serde_json::json!({ "workflow_id": workflow_id.to_string(), }); if let Some(secret) = webhook_secret { resp["webhook_secret"] = serde_json::Value::String(secret); } Ok(IngestResult { event_id: event.id.to_hex(), accepted: true, message: format!("response:{}", resp), }) } async fn handle_workflow_trigger( tenant: &TenantContext, state: &Arc, event: &Event, auth: &IngestAuth, ) -> Result { let self_bytes = auth.pubkey().to_bytes().to_vec(); // 1. Extract workflow reference from `d` tag or `e` tag let workflow_id_str = extract_d_tag(event) .or_else(|| extract_e_tag(event)) .ok_or_else(|| { IngestError::Rejected("invalid: missing workflow reference (d or e tag)".into()) })?; let workflow_id = Uuid::parse_str(&workflow_id_str) .map_err(|_| IngestError::Rejected("invalid: bad workflow_id format".into()))?; // 2. Validate workflow exists — scoped to the caller's community. The same // workflow UUID can exist in another community; a bare-id lookup could load // B's workflow and then satisfy the membership check below against B's // colliding channel, letting B trigger A's workflow. let community_id = tenant.community(); let workflow = state .db .get_workflow(community_id, workflow_id) .await .map_err(|_| IngestError::Rejected("invalid: workflow not found".into()))?; // 3. Manual triggers execute with the workflow owner's authority, so only // the owner may start them. Channel membership alone is insufficient: a // member could otherwise invoke another user's webhook or message actions. if workflow.owner_pubkey != self_bytes { return Err(IngestError::Rejected( "forbidden: not authorized to trigger this workflow".into(), )); } // SEC-006: manual triggers must honor the workflow's lifecycle state and // recheck the owner's *current* channel authority before creating a run. // Without this, a disabled workflow — including one disabled because its // owner was removed from the channel — could still be fired by the owner. if !workflow.enabled || workflow.status != buzz_db::workflow::WorkflowStatus::Active { return Err(IngestError::Rejected( "forbidden: workflow is disabled or inactive".into(), )); } let def: buzz_workflow::WorkflowDef = serde_json::from_value(workflow.definition.clone()) .map_err(|e| IngestError::Internal(format!("error: corrupt workflow definition: {e}")))?; let Some(wf_channel_id) = workflow.channel_id else { // No channel scope means no channel authority to verify — fail closed. return Err(IngestError::Rejected( "forbidden: workflow has no channel scope".into(), )); }; state .workflow_engine .check_owner_authority(community_id, wf_channel_id, &workflow.owner_pubkey, &def) .await .map_err(|_| { IngestError::Rejected("forbidden: not authorized to trigger this workflow".into()) })?; // Persist the command event under the workflow channel even though the // trigger event itself only carries the workflow UUID. Storing channel // triggers as global events leaks workflow IDs to unrelated relay members. let tx = match persist_command_event(state, tenant, event, workflow.channel_id).await? { PersistResult::Duplicate => { return Ok(IngestResult { event_id: event.id.to_hex(), accepted: true, message: "duplicate: already processed".into(), }); } PersistResult::Inserted(tx) => tx, }; // 4. Execute: create workflow run let mut trigger_ctx = TriggerContext { channel_id: workflow .channel_id .map(|id| id.to_string()) .unwrap_or_default(), author: hex::encode(&self_bytes), ..Default::default() }; if !event.content.is_empty() { if let Ok(serde_json::Value::Object(map)) = serde_json::from_str(&event.content) { for (k, v) in map { let val_str = match v { serde_json::Value::String(s) => s, other => other.to_string(), }; trigger_ctx.webhook_fields.insert(k, val_str); } } } let trigger_ctx_json = serde_json::to_value(&trigger_ctx).ok(); let event_id_bytes = event.id.as_bytes().to_vec(); let run_id = state .db .create_workflow_run( community_id, workflow_id, Some(&event_id_bytes), trigger_ctx_json.as_ref(), ) .await .map_err(|e| IngestError::Internal(format!("error: db create_workflow_run: {e}")))?; // Commit: event + run creation succeeded atomically. tx.commit() .await .map_err(|e| IngestError::Internal(format!("error: commit transaction: {e}")))?; // 5. Spawn workflow execution let engine = Arc::clone(&state.workflow_engine); let db = state.db.clone(); let def_value = workflow.definition.clone(); let trigger_ctx_clone = trigger_ctx.clone(); tokio::spawn(async move { let def: buzz_workflow::WorkflowDef = match serde_json::from_value(def_value) { Ok(d) => d, Err(e) => { tracing::error!("workflow_trigger: failed to parse definition: {e}"); if let Err(db_err) = db .update_workflow_run( community_id, run_id, RunStatus::Failed, 0, &serde_json::json!([]), Some(&format!("definition parse error: {e}")), ) .await { tracing::error!("workflow_trigger: failed to mark run as failed: {db_err}"); } return; } }; let result = buzz_workflow::executor::execute_from_step( &engine, community_id, run_id, &def, &trigger_ctx_clone, 0, None, ) .await; engine .finalize_run(community_id, run_id, result, None) .await; }); // 6. Return response Ok(IngestResult { event_id: event.id.to_hex(), accepted: true, message: format!( "response:{}", serde_json::json!({ "run_id": run_id.to_string(), }) ), }) } /// Enforce the approver_spec field against the requesting pubkey. /// /// Accepted specs: /// - `""` or `"any"` — any authenticated user may approve. /// - 64-char lowercase hex string — only that exact pubkey may approve. /// /// All other formats are rejected (fail-closed). fn check_approver_spec(approver_spec: &str, requester_hex: &str) -> Result<(), IngestError> { let spec = approver_spec.trim(); // Empty or "any" — anyone may approve if spec.is_empty() || spec == "any" { return Ok(()); } // Exact pubkey match (64-char hex, case-insensitive) if spec.len() == 64 && spec.chars().all(|c| c.is_ascii_hexdigit()) { if requester_hex.to_lowercase() == spec.to_lowercase() { return Ok(()); } return Err(IngestError::Rejected( "forbidden: not the designated approver for this request".into(), )); } // Role-based or unrecognised — fail closed Err(IngestError::Rejected(format!( "forbidden: approver spec '{}' is not yet supported", spec ))) } async fn handle_approval_grant( tenant: &TenantContext, state: &Arc, event: &Event, auth: &IngestAuth, ) -> Result { let self_bytes = auth.pubkey().to_bytes().to_vec(); let self_hex = hex::encode(&self_bytes); // 1. Extract approval reference from `e` tag (references the approval-requested event) // or `d` tag (contains the token hash hex) let token_hash_hex = extract_d_tag(event) .or_else(|| extract_e_tag(event)) .ok_or_else(|| { IngestError::Rejected("invalid: missing approval reference (d or e tag)".into()) })?; let token_hash = hex::decode(&token_hash_hex) .map_err(|_| IngestError::Rejected("invalid: bad approval token hash hex".into()))?; // 2. Look up the approval record let approval = state .db .get_approval_by_stored_hash(tenant.community(), &token_hash) .await .map_err(|_| IngestError::Rejected("invalid: approval not found".into()))?; // 3. Validate approval is pending and not expired if approval.status != ApprovalStatus::Pending { return Err(IngestError::Rejected(format!( "invalid: approval already {}", approval.status ))); } if Utc::now() > approval.expires_at { return Err(IngestError::Rejected( "invalid: approval token has expired".into(), )); } // 4. Validate caller is authorized approver check_approver_spec(&approval.approver_spec, &self_hex)?; // Persist the command event — returns open transaction let tx = match persist_command_event(state, tenant, event, None).await? { PersistResult::Duplicate => { return Ok(IngestResult { event_id: event.id.to_hex(), accepted: true, message: "duplicate: already processed".into(), }); } PersistResult::Inserted(tx) => tx, }; // 5. Execute: update approval status to granted let note = if event.content.is_empty() { None } else { Some(event.content.as_str()) }; let updated = state .db .update_approval_by_stored_hash( tenant.community(), &token_hash, ApprovalStatus::Granted, Some(&self_bytes), note, ) .await .map_err(|e| IngestError::Internal(format!("error: db update_approval: {e}")))?; if !updated { return Err(IngestError::Rejected( "invalid: approval already acted on (race)".into(), )); } // Commit: event + approval update succeeded atomically. tx.commit() .await .map_err(|e| IngestError::Internal(format!("error: commit transaction: {e}")))?; // 6. Resume workflow execution (post-commit, async) let community_id = tenant.community(); let run_id = approval.run_id; let workflow_id = approval.workflow_id; let resume_index = approval.step_index as usize + 1; let engine = Arc::clone(&state.workflow_engine); let db = state.db.clone(); tokio::spawn(async move { resume_workflow_after_approval(engine, db, community_id, run_id, workflow_id, resume_index) .await; }); // 7. Return response Ok(IngestResult { event_id: event.id.to_hex(), accepted: true, message: format!( "response:{}", serde_json::json!({ "status": "granted", "run_id": run_id.to_string(), }) ), }) } async fn handle_approval_deny( tenant: &TenantContext, state: &Arc, event: &Event, auth: &IngestAuth, ) -> Result { let self_bytes = auth.pubkey().to_bytes().to_vec(); let self_hex = hex::encode(&self_bytes); // 1. Extract approval reference let token_hash_hex = extract_d_tag(event) .or_else(|| extract_e_tag(event)) .ok_or_else(|| { IngestError::Rejected("invalid: missing approval reference (d or e tag)".into()) })?; let token_hash = hex::decode(&token_hash_hex) .map_err(|_| IngestError::Rejected("invalid: bad approval token hash hex".into()))?; // 2. Look up the approval record let approval = state .db .get_approval_by_stored_hash(tenant.community(), &token_hash) .await .map_err(|_| IngestError::Rejected("invalid: approval not found".into()))?; // 3. Validate approval is pending and not expired if approval.status != ApprovalStatus::Pending { return Err(IngestError::Rejected(format!( "invalid: approval already {}", approval.status ))); } if Utc::now() > approval.expires_at { return Err(IngestError::Rejected( "invalid: approval token has expired".into(), )); } // 4. Validate caller is authorized approver check_approver_spec(&approval.approver_spec, &self_hex)?; // Persist the command event — returns open transaction let tx = match persist_command_event(state, tenant, event, None).await? { PersistResult::Duplicate => { return Ok(IngestResult { event_id: event.id.to_hex(), accepted: true, message: "duplicate: already processed".into(), }); } PersistResult::Inserted(tx) => tx, }; // 5. Execute: update approval status to denied let note = if event.content.is_empty() { None } else { Some(event.content.as_str()) }; let updated = state .db .update_approval_by_stored_hash( tenant.community(), &token_hash, ApprovalStatus::Denied, Some(&self_bytes), note, ) .await .map_err(|e| IngestError::Internal(format!("error: db update_approval: {e}")))?; if !updated { return Err(IngestError::Rejected( "invalid: approval already acted on (race)".into(), )); } // Commit: event + approval denial succeeded atomically. tx.commit() .await .map_err(|e| IngestError::Internal(format!("error: commit transaction: {e}")))?; // 6. Cancel the workflow run (post-commit, async) let community_id = tenant.community(); let run_id = approval.run_id; let pubkey_hex = self_hex.clone(); let db = state.db.clone(); tokio::spawn(async move { let run = match db.get_workflow_run(community_id, run_id).await { Ok(r) => r, Err(e) => { tracing::error!("approval_deny: failed to fetch run {run_id}: {e}"); return; } }; if run.status != RunStatus::WaitingApproval { tracing::warn!( "approval_deny: run {run_id} has status '{}', expected 'waiting_approval'", run.status ); return; } let cancel_msg = format!("workflow cancelled: approval denied by {pubkey_hex}"); if let Err(e) = db .update_workflow_run( community_id, run_id, RunStatus::Cancelled, run.current_step, &run.execution_trace, Some(&cancel_msg), ) .await { tracing::error!("approval_deny: failed to cancel run {run_id}: {e}"); } }); // 7. Return response Ok(IngestResult { event_id: event.id.to_hex(), accepted: true, message: format!( "response:{}", serde_json::json!({ "status": "denied", "run_id": run_id.to_string(), }) ), }) } /// Resume a suspended workflow run after an approval gate has been granted. async fn resume_workflow_after_approval( engine: Arc, db: buzz_db::Db, community_id: CommunityId, run_id: Uuid, workflow_id: Uuid, resume_index: usize, ) { let run = match db.get_workflow_run(community_id, run_id).await { Ok(r) => r, Err(e) => { tracing::error!("resume_workflow: failed to fetch run {run_id}: {e}"); return; } }; // Guard: only resume runs that are actually waiting for approval if run.status != RunStatus::WaitingApproval { tracing::warn!( "resume_workflow: run {run_id} has status '{}', expected 'waiting_approval'", run.status ); return; } let workflow = match db.get_workflow(community_id, workflow_id).await { Ok(w) => w, Err(e) => { tracing::error!("resume_workflow: failed to fetch workflow {workflow_id}: {e}"); return; } }; let def: buzz_workflow::WorkflowDef = match serde_json::from_value(workflow.definition.clone()) { Ok(d) => d, Err(e) => { tracing::error!("resume_workflow: failed to parse workflow definition: {e}"); if let Err(db_err) = db .update_workflow_run( community_id, run_id, RunStatus::Failed, run.current_step, &run.execution_trace, Some(&format!("definition parse error: {e}")), ) .await { tracing::error!("resume_workflow: failed to mark run as failed: {db_err}"); } return; } }; // Reconstruct step_outputs from execution trace for template resolution let mut initial_outputs: std::collections::HashMap = std::collections::HashMap::new(); if let Some(trace_arr) = run.execution_trace.as_array() { for entry in trace_arr { if let (Some(step_id), Some(output)) = ( entry.get("step_id").and_then(|v| v.as_str()), entry.get("output"), ) { initial_outputs.insert(step_id.to_string(), output.clone()); } } } // Restore trigger context for {{trigger.*}} templates let trigger_ctx: TriggerContext = run .trigger_context .as_ref() .and_then(|v| serde_json::from_value(v.clone()).ok()) .unwrap_or_default(); // Execute remaining steps let existing_trace = run.execution_trace.as_array().cloned(); let result = buzz_workflow::executor::execute_from_step( &engine, community_id, run_id, &def, &trigger_ctx, resume_index, Some(initial_outputs), ) .await; engine .finalize_run(community_id, run_id, result, existing_trace) .await; }