Files
buzz/crates/buzz-relay/src/handlers/command_executor.rs
T
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

1371 lines
47 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Command executor — transactional event processing for command kinds.
//!
//! Command kinds (4101041012, 30620, 46020, 4603046031) 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<AppState>,
event: Event,
auth: IngestAuth,
) -> Result<IngestResult, IngestError> {
// 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<AppState>,
tenant: &TenantContext,
event: &Event,
channel_id_override: Option<Uuid>,
) -> Result<PersistResult, IngestError> {
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<chrono::Utc>, Vec<u8>)> = 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<String> {
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<String> {
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<String> {
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<String> {
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<String> {
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<Vec<u8>, 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<u8> {
Sha256::digest(json_str.as_bytes()).to_vec()
}
async fn handle_dm_open(
tenant: &TenantContext,
state: &Arc<AppState>,
event: &Event,
auth: &IngestAuth,
) -> Result<IngestResult, IngestError> {
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<u8>> = 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<u8>> = 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<String> = 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<AppState>,
event: &Event,
auth: &IngestAuth,
) -> Result<IngestResult, IngestError> {
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<Vec<u8>> = 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<AppState>,
event: &Event,
auth: &IngestAuth,
) -> Result<IngestResult, IngestError> {
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<AppState>,
event: &Event,
auth: &IngestAuth,
) -> Result<IngestResult, IngestError> {
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<AppState>,
event: &Event,
auth: &IngestAuth,
) -> Result<IngestResult, IngestError> {
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<AppState>,
event: &Event,
auth: &IngestAuth,
) -> Result<IngestResult, IngestError> {
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<AppState>,
event: &Event,
auth: &IngestAuth,
) -> Result<IngestResult, IngestError> {
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<buzz_workflow::WorkflowEngine>,
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<String, serde_json::Value> =
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;
}