Files
buzz/crates/buzz-workflow/src/lib.rs
Logan Johnson d97f6b4c6e feat(relay): add announce channels with a join_role setting (#8143)
🤖
## Summary
Channels are Buzz's main way to control access, but today they cannot
separate reading from writing. In an open channel, anyone who can read
can also write. So there is no way to say "many people read, a few
people write". Announcements, release feeds, plugin release records and
shared configuration all need that.

This PR adds **announce channels**: anyone can follow and read them, but
only members can post. It does this with one new channel setting and
without changing what any role means.

- **Roles mean the same thing in every channel.** A *guest* can read and
leave, but cannot write. A *member* (or *bot*) can write. *Admins* and
*owners* also manage the channel. The relay already had the guest role,
but no client used it until now.
- **A new setting, `join_role` (`member` | `guest`), picks the role a
new person gets** when nobody chooses one: when they join by themselves,
or when someone adds them without a role. The default, `member`, keeps
today's behavior for every existing channel.
- **An open channel with `join_role = guest` is an announce channel.**
Anyone can find, read and join it. People who join are guests, so they
cannot write until someone makes them a member.

The relay enforces these rules, so a client cannot get around them.

What each role can do (the same in every channel):

| Action | Guest | Member or bot | Admin / owner |
| --- | --- | --- | --- |
| Read, leave | ✅ | ✅ | ✅ |
| Post, reply, edit, delete own, react, artifacts, canvas, forum, pins,
topic, typing, workflow messages, save or run workflows, huddles | ❌ | ✅
| ✅ |
| Add people | ❌ | ✅ as member, guest or bot | ✅ any role |
| Make a guest a member (person or agent) | ❌ | ✅ | ✅ |
| Any other role change | ❌ | ❌ | ✅ |
| Change name, about, visibility, TTL, `join_role` | ❌ | ❌ | ✅ |

Other rules:

- **Joining by yourself.** A person who joins (kind:9021), or adds
themselves (kind:9000 with their own key), gets the channel's
`join_role`. They may ask for `join_role` or a lower role, never a
higher one. So in an announce channel nobody can join as a member by
themselves.
- **Non-members.** A non-member may write only where joining would let
them write: an open channel with `join_role = member`. That is today's
behavior for open channels.
- **Agents.** Agents follow the same rules as people. Any member who can
write may make a guest agent a member. Limits that only an agent's owner
may lift belong to the later IFC work (its "restricted" agent level),
not to this PR. That work only refuses more actions, so it can be added
later without breaking anything here.
- **Leaving always works.** A member may leave an archived channel too
(kind:9022, or kind:9001 naming themselves). The last owner still cannot
leave.
- **Agent owners keep edit and delete.** Today the human owner of an
agent can edit (kind:40003) and delete (kind:9005) that agent's messages
without being a member. This PR keeps that existing behavior in announce
channels too, and documents and tests it. It does not let the owner
post.
- **Changing the setting changes no roles.** Existing members can still
write after a change to `guest`. Existing guests stay read-only after a
change back to `member`.

A refused write gets `restricted: only members can post in this
channel`.

## Details
- **One write rule.** The relay's channel write check decides from the
actor's role and the channel's `join_role`. Some event types skip that
check and run their own: message edit, deleting your own message
(kind:9005), topic and purpose (kind:9002), add-member (kind:9000),
workflow messages and joining a huddle. Each of these now uses the same
rule. Membership changes (add, remove, leave) do not need write access,
so a guest can always leave. If the relay finds a stored `join_role`
value it does not know, it treats it as `guest`, so an unknown value can
never give more access.
- **Admission is decided in the database transaction.** The membership
write (`put_member`) makes the whole admission decision inside its
transaction, after it takes the channel's membership lock: `join_role`
for a missing role tag, the self-join ceiling, guests cannot add people,
who may grant admin or owner, and role changes
(`MemberRole::may_change_role`). The relay's request check uses the same
rules, but the database is the authority, so a write that skips the
request check cannot get around them. A change to `visibility` or
`join_role` takes the same lock, so an add and a setting change cannot
interleave.
- **No creator exception.** `put_member` used to give owner to a
channel's creator who added themselves to a private channel without an
active membership. A removed creator could use that: their join
(kind:9021) passes the open-channel check, the channel becomes private
before the join takes the membership lock, and the join restored them as
owner. Channel creation already makes the creator owner in the same
transaction, so the exception is removed. A removed creator is now
admitted like anyone else.
- **Huddles.** Joining a huddle needs write access in the huddle channel
**and** in its parent channel. The relay checks both before the join.
When it commits the join, it applies the same rule again while it holds
both channels' membership locks: a member needs a role that can post,
and a non-member needs an open channel whose `join_role` is `member`. A
guest in either channel cannot join. A join is also refused when the
joiner loses membership or the channel's `join_role` changes before the
commit.
- **One lock order.** Each writer that takes more than one of a
channel's locks takes them in this order: membership lock, TTL lock,
channel row. Settings changes, roster publication, TTL refresh and
huddle joins all use this order. Before this change, a `join_role`
change on an ephemeral channel at the same time as a roster publication
could deadlock.
- **Workflows.** Saving a workflow (kind:30620) skips the generic write
check, so it now checks the sender's role itself and needs a writing
role before anything is stored. Every path that starts a workflow run,
including the manual trigger (kind:46020), needs the workflow's owner to
hold a writing role now (and an elevated role for an elevated workflow).
So a workflow saved while its owner was a member stops running after the
owner is made a guest.
- **Channel names are checked before storage.** kind:9002 and kind:9007
refuse a name longer than the `channels.name` column (255 characters)
before the event is stored. Before this, the relay accepted the event,
then the name update failed, and the rest of the update (for example
`join_role`) was silently skipped. This PR only adds this check. Storing
the event and applying the update in one transaction is a larger change,
and it is not in this PR.
- **Changing the setting.** Owners and admins send a channel-metadata
update (kind:9002) with `["join_role", "guest"]` or `["join_role",
"member"]`. Buzz applies these updates as partial updates; a missing tag
means "no change". The relay posts a `join_role_changed` system message
in the channel.
- **What clients see.** The relay signs a channel description event
(kind:39000) that clients read. One shared builder now writes it for
both the relay and the `buzz-admin` backfill, which had a copy that had
drifted from the relay's. It always publishes an explicit `["join_role",
…]` tag, plus the standard NIP-29 group access flags:

| `visibility` | `join_role` | `public` / `private` | `restricted` |
`closed` |
  | --- | --- | --- | --- | --- |
  | open | `member` | public | | |
  | open | `guest` | public | ✓ | |
  | private | either | private | ✓ | ✓ |

This also fixes an old bug: `closed` ("join requests are ignored") was
published for every channel, even open channels that accept joins.
- **CLI and SDK.** `buzz channels update --join-role guest|member`.
`buzz_sdk::build_update_channel` takes a new `join_role` argument and
always writes it as an explicit tag.
- **Docs.** `NOSTR.md` has an "Announce channels" section with the role
table and the flag table.
- **Tests.** Relay end-to-end tests, run in the CI relay E2E step:
- `test_nip29_announce_channel_wire`: who can set `join_role`, the
kind:39000 flags, joining as a guest, refused guest and non-member
posts, reactions and adds, member posts, a member making a guest a
member who then posts, changing the setting back without changing roles,
and leaving.
- `test_nip29_member_raises_guest_agent`: a member who is not the
agent's owner can make a guest agent a member.
- `test_nip29_announce_channel_wire_workflow_send_needs_write_role`: a
guest's workflow message is refused; a member's is accepted.
- `test_nip29_departure_wire_archived_channel_allows_self_leave`: guests
and members can leave an archived channel.
- `test_private_channel_any_member_can_invite` now expects a
private-channel guest to be refused when it adds someone (it used to
expect success).
- `test_nip29_announce_channel_wire_workflow_commands_need_write_role`:
saving and triggering a workflow, by a member and by a guest, in
announce and normal channels; a guest cannot run a workflow saved while
it was a member.
-
`test_nip29_announce_channel_wire_overlong_name_is_refused_before_storage`:
a kind:9002 with an overlong name and `join_role` is refused, and
kind:39000 still shows the old settings.
- `test_nip29_announce_channel_wire_agent_owner_exception`: an agent's
owner, as a guest and as a non-member, can edit and delete the agent's
messages, while ordinary guests are still refused.
- `test_nip29_guest_is_read_only_in_normal_channel`: a guest in an
ordinary channel cannot write, and joining an ordinary channel still
gives `member`.

Unit tables cover the self-join rule for each `join_role`, and every
actor, current role and requested role for the role-change rule.
Database tests check the admission rules in `put_member`
(`put_member_enforces_announce_admission`), that an add which waits on
an uncommitted change to `guest` is decided on the new setting
(`put_member_waits_for_a_concurrent_join_role_change`), and that a
member can make a guest a member but not an admin. Unit tests cover the
name limit and the workflow owner rule
(`owner_authority_denies_roles_that_cannot_write`). A relay test covers
huddle joins with different roles in the huddle and its parent, and a
commit after a demotion, a `join_role` change or a lost membership
(`huddle_join_needs_a_write_role_in_the_huddle_and_its_parent`).
Lock-order tests:
`join_role_change_waits_for_a_roster_publication_on_an_ephemeral_channel`
reproduced a real deadlock (`40P01`) against the old order.
`f2d_join_lock_order_serializes_membership_and_settings_writers` checks
that membership and settings writers wait for a join and then succeed.
`removed_creator_cannot_rejoin_a_private_channel` checks that
`put_member` refuses a removed creator on a private channel, and
`removed_creator_join_racing_a_private_switch_is_refused` holds the
membership lock so a removed creator's kind:9021 passes the open-channel
check, then makes the channel private before the join continues. Each
new test was run against the old code and failed.

## Rollout
1. **Before the deploy, count guest rows** (`SELECT count(*) FROM
channel_members WHERE role = 'guest' AND removed_at IS NULL`). We expect
none. Any guest that exists loses the ability to post, change the topic
and add people when this ships. If some exist, decide first whether to
make them members.
2. Deploy. Every channel stays `join_role = member`, so nothing else
changes.
3. **Do not set any channel to `join_role = guest` until no old relay is
running.** Old relays ignore the column, so they would let guests and
non-members write in that channel.
4. Rerun the `buzz-admin` channel reconcile so open channels drop the
wrong `closed` flag.

## Migration and rollback
- Migration `0067` adds `channels.join_role TEXT NOT NULL DEFAULT
'member'` with a CHECK constraint. Postgres stores a constant default in
table metadata, so existing rows are not rewritten and there is no
backfill. The relay runs the migration at startup.
- Existing channels keep the old `closed` flag until their next change,
or until the reconcile in Rollout step 4.
- **Before rolling back the relay**, set every announce channel back to
`join_role = member`. An older relay ignores the column and does not
stop guests from writing, so those channels would accept posts from
their guests and give `member` to new joiners again.

## Not in this PR
Client UI: a Follow button, hiding the composer for guests, and an admin
toggle. Clients that ignore the new tag keep working. A guest who tries
to post sees the `restricted` error.

## Stack
1. #8143 (this PR): announce channels
2. #8494: store the bot role only for keys with a recorded agent owner

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-21 16:01:11 -04:00

2108 lines
83 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.

#![deny(unsafe_code)]
#![warn(missing_docs)]
//! `buzz-workflow` — Workflow engine for Buzz.
//!
//! Channel-scoped automations with sequential execution, variable substitution,
//! conditional logic, and execution traces.
//!
//! ## Architecture
//!
//! - [`WorkflowEngine`] — top-level handle; lives in `AppState`
//! - [`schema`] — YAML/JSON definition types (`WorkflowDef`, `TriggerDef`, `ActionDef`, `Step`)
//! - [`executor`] — sequential execution, template resolution, condition evaluation
//! - [`error`] — [`WorkflowError`] enum
//!
//! ## Usage
//!
//! ```rust,ignore
//! let engine = Arc::new(WorkflowEngine::new(db, WorkflowConfig::default()));
//!
//! // Parse and validate a YAML definition.
//! let (def, json) = WorkflowEngine::parse_yaml(yaml_str)?;
//!
//! // React to an incoming event (called from event handler post-store hook).
//! // The community is the event's server-resolved tenant, threaded from the
//! // relay's bound `TenantContext` — the same workflow UUID can exist in two
//! // communities, so execution is always scoped to its owner.
//! engine.on_event(community_id, &stored_event).await?;
//!
//! // Run the background scheduler (cron triggers).
//! tokio::spawn(async move { engine.run().await });
//! ```
pub mod action_sink;
pub mod error;
pub mod executor;
pub mod schema;
pub use action_sink::{ActionSink, ActionSinkError};
pub use error::{PartialProgress, WorkflowError};
pub use executor::ExecutionResult;
pub use schema::{ActionDef, Step, TriggerDef, WorkflowDef};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::OnceLock;
use buzz_core::kind::{event_kind_u32, is_workflow_execution_kind, KIND_REACTION};
use buzz_core::tenant::CommunityId;
use buzz_db::workflow::RunStatus;
use buzz_db::Db;
use chrono::{DateTime, Utc};
use dashmap::DashMap;
use tokio::sync::Semaphore;
use uuid::Uuid;
/// Runtime configuration for the workflow engine.
#[derive(Clone, Debug)]
pub struct WorkflowConfig {
/// Maximum number of concurrently executing workflow runs. Default: 100.
pub max_concurrent: usize,
/// Default per-step timeout in seconds. Default: 300 (5 minutes).
pub default_timeout_secs: u64,
}
impl Default for WorkflowConfig {
fn default() -> Self {
Self {
max_concurrent: 100,
default_timeout_secs: 300,
}
}
}
/// The workflow engine. Clone is cheap (Arc-backed DB pool + semaphore).
pub struct WorkflowEngine {
pub(crate) db: Db,
pub(crate) config: WorkflowConfig,
/// Semaphore enforcing `config.max_concurrent` simultaneous workflow runs.
pub(crate) run_semaphore: Arc<Semaphore>,
/// Last-fired timestamps for interval-triggered workflows, keyed by
/// `(community_id, workflow_id)`. The same workflow UUID can exist in two
/// communities (the PK is `(community_id, id)`); keying by bare id would let
/// one community's interval fire suppress the other's for the interval.
/// In-memory only — lost on restart. Missed fires during downtime are
/// not replayed (acceptable for MVP).
pub(crate) last_fired: DashMap<(CommunityId, Uuid), DateTime<Utc>>,
/// Action sink for executing side-effects (SendMessage, etc.).
/// Late-initialized via [`set_action_sink`] after `AppState` construction.
pub(crate) action_sink: OnceLock<Arc<dyn ActionSink>>,
/// Short-TTL cache for the per-event enabled-workflow lookup, keyed
/// `(community_id, channel_id)`. Most channels have no workflows, so this
/// removes one SELECT from nearly every ingested event.
///
/// Consistency: the relay invalidates this cache on its own pod at the two
/// workflow mutation sites (command upsert, NIP-09 deletion). There is
/// deliberately no cross-pod invalidation — workflow triggering is not an
/// access-control fence, so the worst case on another pod is a just-deleted
/// workflow firing (or a just-created one missing events) for up to the TTL.
/// The same TTL also bounds the same-pod look-aside race (a stale fill
/// landing just after an invalidation). Workflow mutations are rare; the
/// 10s window matches the relay's other moka caches (see `AppState` in
/// `buzz-relay`).
pub(crate) workflow_cache:
moka::sync::Cache<(CommunityId, Uuid), Arc<Vec<buzz_db::workflow::WorkflowRecord>>>,
}
impl WorkflowEngine {
/// Create a new `WorkflowEngine`.
pub fn new(db: Db, config: WorkflowConfig) -> Self {
let permits = config.max_concurrent.max(1);
let run_semaphore = Arc::new(Semaphore::new(permits));
Self {
db,
config,
run_semaphore,
last_fired: DashMap::new(),
action_sink: OnceLock::new(),
workflow_cache: moka::sync::Cache::builder()
.max_capacity(10_000)
.time_to_live(std::time::Duration::from_secs(10))
.build(),
}
}
/// Drop the cached enabled-workflow list for a channel.
///
/// Must be called after any write to a workflow's trigger eligibility or
/// channel binding (currently the relay's command upsert and NIP-09
/// deletion paths) so same-pod trigger matching sees the change
/// immediately instead of after the cache TTL.
pub fn invalidate_channel_workflows(&self, community_id: CommunityId, channel_id: Uuid) {
self.workflow_cache.invalidate(&(community_id, channel_id));
}
/// Fail-closed pre-run authority gate (SEC-006).
///
/// A workflow executes with its **owner's** standing authority long after
/// the definition was saved, so every run-creation door must recheck the
/// owner's *current* channel authority immediately before creating a run:
///
/// - the owner must still be an active member of the workflow's channel,
/// in a role that can write (a guest cannot);
/// - if the definition contains an exfiltration-capable action
/// (`call_webhook`), the owner must currently hold the `owner` or
/// `admin` role.
///
/// Any lookup error denies (fail-closed): a removed owner must never keep
/// exfiltration authority because a membership read happened to fail.
pub async fn check_owner_authority(
&self,
community_id: CommunityId,
channel_id: Uuid,
owner_pubkey: &[u8],
def: &WorkflowDef,
) -> Result<(), WorkflowError> {
let role = self
.db
.get_member_role(community_id, channel_id, owner_pubkey)
.await
.map_err(|e| {
WorkflowError::Unauthorized(format!(
"owner authority lookup failed (fail-closed): {e}"
))
})?;
if owner_authority_allows(role.as_deref(), def.requires_elevated_authority()) {
Ok(())
} else {
Err(WorkflowError::Unauthorized(
"workflow owner lacks current channel authority".into(),
))
}
}
/// Set the action sink. Called once after `AppState` construction.
///
/// # Panics
/// Panics if called more than once.
pub fn set_action_sink(&self, sink: Arc<dyn ActionSink>) {
if self.action_sink.set(sink).is_err() {
panic!("action_sink already initialized");
}
}
/// Get the action sink reference.
///
/// Returns `Err(WorkflowError)` if the sink has not been initialized via
/// [`set_action_sink`]. This avoids a panic if the engine is used before
/// wiring is complete.
pub(crate) fn action_sink(&self) -> Result<&dyn ActionSink, WorkflowError> {
self.action_sink.get().map(|s| s.as_ref()).ok_or_else(|| {
WorkflowError::InvalidDefinition(
"action_sink not initialized — call set_action_sink() before executing workflows"
.into(),
)
})
}
/// Parse and validate a YAML workflow definition.
///
/// Returns `(WorkflowDef, canonical_json)` on success. The canonical JSON
/// is suitable for storage in the `definition` column.
pub fn parse_yaml(yaml: &str) -> Result<(WorkflowDef, String), WorkflowError> {
schema::parse_yaml(yaml)
}
/// Finalize a workflow run after execution completes or fails.
///
/// This is the **single** place that maps an executor result to a DB status
/// update. All execution paths (event-triggered, manual trigger/webhook,
/// approval resume) call this instead of duplicating the 3-way match.
///
/// `existing_trace` is prepended to the executor's trace — used by the
/// approval-resume path where pre-approval steps already have trace entries.
pub async fn finalize_run(
&self,
community_id: CommunityId,
run_id: uuid::Uuid,
result: Result<ExecutionResult, (WorkflowError, PartialProgress)>,
existing_trace: Option<Vec<serde_json::Value>>,
) {
let prefix = existing_trace.unwrap_or_default();
match result {
Ok(result) => {
let mut full_trace = prefix;
full_trace.extend(result.trace);
let trace_json = serde_json::Value::Array(full_trace);
let step_count = result.step_index as i32;
if result.approval_token.is_some() {
// Approval gates are not yet implemented (WF-08).
// Fail explicitly rather than creating unreachable WaitingApproval rows.
tracing::warn!(
run_id = %run_id,
step_index = result.step_index,
"Workflow hit approval gate — not yet implemented, marking as failed"
);
if let Err(e) = self
.db
.update_workflow_run(
community_id,
run_id,
RunStatus::Failed,
step_count,
&trace_json,
Some(buzz_db::workflow::WorkflowRunFailure {
code: "approval_not_supported",
message: "approval gates not yet implemented — see WF-08",
}),
)
.await
{
tracing::error!(
run_id = %run_id,
"Failed to update run to Failed (approval gate): {e}"
);
}
} else {
tracing::info!(run_id = %run_id, "Workflow run completed");
if let Err(e) = self
.db
.update_workflow_run(
community_id,
run_id,
RunStatus::Completed,
step_count,
&trace_json,
None,
)
.await
{
tracing::error!(
run_id = %run_id,
"Failed to update run to Completed: {e}"
);
}
}
}
Err((e, progress)) => {
tracing::error!(run_id = %run_id, "Workflow run failed: {e}");
let mut full_trace = prefix;
full_trace.extend(progress.trace);
let trace_json = serde_json::Value::Array(full_trace);
if let Err(db_err) = self
.db
.update_workflow_run(
community_id,
run_id,
RunStatus::Failed,
progress.step_index as i32,
&trace_json,
Some(buzz_db::workflow::WorkflowRunFailure {
code: e.code(),
message: &e.to_string(),
}),
)
.await
{
tracing::error!(
run_id = %run_id,
"Failed to update run to Failed: {db_err}"
);
}
}
}
}
/// Called from the event handler post-store hook for every stored event.
///
/// Checks whether any workflow in the event's channel has a matching trigger.
/// Workflow execution events (kinds 46001–46012) are excluded to prevent loops.
///
/// `community_id` is the server-resolved community the event was stored
/// under — `StoredEvent` does not carry it, and the same channel UUID can
/// exist in two communities, so the workflow lookup/run-creation must be
/// scoped to the caller's tenant or community B could trigger community A's
/// workflow on a colliding channel id.
///
/// The method takes `self: &Arc<Self>` so that the spawned task can hold a
/// clone of the `Arc` without requiring `'static` on `&self`.
pub async fn on_event(
self: &Arc<Self>,
community_id: CommunityId,
event: &buzz_core::StoredEvent,
) -> Result<(), WorkflowError> {
let Some(channel_id) = event.channel_id else {
tracing::debug!(
event_id = %event.event.id.to_hex(),
kind = event_kind_u32(&event.event),
"Skipping workflow trigger — event has no channel_id"
);
return Ok(());
};
let kind_u32 = event_kind_u32(&event.event);
// Exclude workflow execution events to prevent infinite loops.
if is_workflow_execution_kind(kind_u32) {
return Ok(());
}
let cache_key = (community_id, channel_id);
let workflows = match self.workflow_cache.get(&cache_key) {
Some(cached) => cached,
None => {
let fresh = Arc::new(
self.db
.list_enabled_channel_workflows(community_id, channel_id)
.await
.map_err(WorkflowError::from)?,
);
self.workflow_cache.insert(cache_key, Arc::clone(&fresh));
fresh
}
};
if workflows.is_empty() {
return Ok(());
}
let trigger_ctx = build_trigger_context(event);
let trigger_ctx_json: serde_json::Value = match serde_json::to_value(&trigger_ctx) {
Ok(v) => v,
Err(e) => {
tracing::error!("Failed to serialize trigger context: {e}");
return Ok(());
}
};
for workflow in workflows.iter() {
let def: WorkflowDef = match serde_json::from_value(workflow.definition.clone()) {
Ok(d) => d,
Err(e) => {
tracing::warn!(workflow_id = %workflow.id, "Failed to parse definition: {e}");
continue;
}
};
if !def.enabled || !trigger_matches_event(&def.trigger, kind_u32) {
continue;
}
if !should_fire_workflow(&def, &trigger_ctx, workflow.id).await {
continue;
}
// SEC-006: recheck the owner's *current* channel authority
// immediately before run creation. The cached workflow list can be
// up to 10s stale, and disable-on-removal can race a concurrent
// event — this per-fire gate is the authoritative, fail-closed
// check that a removed (or under-privileged, for exfiltration
// definitions) owner cannot cause a run.
if let Err(e) = self
.check_owner_authority(community_id, channel_id, &workflow.owner_pubkey, &def)
.await
{
tracing::warn!(
workflow_id = %workflow.id,
"Skipping workflow — owner authority check failed: {e}"
);
continue;
}
let trigger_event_id_bytes = event.event.id.as_bytes().to_vec();
let run_id = match self
.db
.create_workflow_run(
community_id,
workflow.id,
Some(&trigger_event_id_bytes),
Some(&trigger_ctx_json),
)
.await
{
Ok(id) => id,
Err(e) => {
tracing::error!(workflow_id = %workflow.id, "Failed to create run: {e}");
continue;
}
};
tracing::debug!(
workflow_id = %workflow.id,
run_id = %run_id,
"Workflow triggered — spawning execution"
);
let engine = Arc::clone(self);
let def_clone = def.clone();
let ctx_clone = trigger_ctx.clone();
tokio::spawn(async move {
let result =
executor::execute_run(&engine, community_id, run_id, &def_clone, &ctx_clone)
.await;
engine
.finalize_run(community_id, run_id, result, None)
.await;
});
}
Ok(())
}
/// Interval prefilter: decide whether the interval workflow should fire this
/// tick, applying the cold-start anchor seed as a side effect.
///
/// `last` is the resolved anchor (in-memory entry if present, else the
/// durable `latest_scheduled_workflow_fire` read). Returns `true` to proceed
/// to the durable claim, `false` to suppress this tick.
///
/// Cold-start liveness: a brand-new interval workflow has no in-memory entry
/// AND no prior claim, so `last` is `None`. `interval_should_fire` then reads
/// `last = now` and suppresses — correct for the first tick (wait a full
/// interval), but the in-memory anchor is only written *after* a successful
/// claim, and no claim is attempted until the prefilter passes. Without
/// seeding, every subsequent tick repeats with `last = None` and the workflow
/// suppresses forever. So on the `None` suppress path we seed `now`: the next
/// tick counts from a real anchor and the workflow fires after one interval.
/// We seed ONLY when `last` was `None`; when `last` is `Some` we are correctly
/// mid-interval and must not advance the anchor, or it would never elapse.
fn interval_prefilter_should_fire(
&self,
community_id: CommunityId,
workflow_id: Uuid,
dur: &str,
last: Option<DateTime<Utc>>,
now: DateTime<Utc>,
) -> bool {
interval_prefilter_should_fire(&self.last_fired, community_id, workflow_id, dur, last, now)
}
/// Background loop for scheduled (cron/interval) triggers.
///
/// Ticks every 60 seconds. For each active workflow with a `Schedule`
/// trigger, checks whether the cron expression or interval has elapsed
/// and spawns execution if so.
///
/// Uses window-based matching for cron expressions to handle tick drift:
/// `schedule.after(&(now - 60s)).next() <= now` instead of `includes(now)`.
///
/// Interval tracking is anchored on the durable scheduled-fire claim:
/// `last_fired` is an in-memory pre-filter, but the
/// `(community_id, workflow_id, scheduled_for)` claim row is the
/// at-most-once boundary across pods and restarts. On the first tick after
/// a restart the interval anchor is seeded from
/// `latest_scheduled_workflow_fire` so a process bounce cannot double-fire
/// within an interval.
pub async fn run(self: &Arc<Self>) {
tracing::info!("WorkflowEngine cron loop started (60s tick)");
loop {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
let now = Utc::now();
let workflows = match self.db.list_all_enabled_workflows().await {
Ok(wf) => wf,
Err(e) => {
tracing::error!("Cron tick: failed to load workflows: {e}");
continue;
}
};
for workflow in &workflows {
// The same workflow UUID may exist in another community; carry
// the row's owning community through fire-tracking, run creation,
// and execution so a fire/run never crosses tenants.
let community_id = workflow.community_id;
let def: schema::WorkflowDef =
match serde_json::from_value(workflow.definition.clone()) {
Ok(d) => d,
Err(e) => {
tracing::warn!(
workflow_id = %workflow.id,
"Cron tick: failed to parse workflow definition: {e}"
);
continue;
}
};
if !def.enabled {
continue;
}
// Fix 2: skip workflows with no channel_id — an empty channel_id
// causes silent downstream failures when the run tries to act on a channel.
let Some(channel_id) = workflow.channel_id else {
tracing::warn!(
workflow_id = %workflow.id,
"Cron tick: skipping schedule workflow with no channel_id"
);
continue;
};
// Resolve the *deterministic* schedule instant this tick is
// firing for. `scheduled_for` is computed identically on every
// pod (cron's own scheduled time, or the interval bucket
// boundary) so all pods collide on a single durable claim —
// never `now`, which is per-pod and would let every pod fire.
let (scheduled_for, trigger_type) = match &def.trigger {
schema::TriggerDef::Schedule {
cron: Some(expr),
interval: None,
} => match cron_fire_instant(expr, now, 60, workflow.id) {
Some(instant) => (instant, "cron"),
None => continue,
},
schema::TriggerDef::Schedule {
cron: None,
interval: Some(dur),
} => {
// Cheap pre-filter: skip the claim attempt when the
// in-memory clock says we're clearly mid-interval. The
// durable claim below is the real at-most-once boundary;
// this only avoids a DB write every tick. Seed the
// anchor from the DB on the first tick after restart so
// a process bounce can't double-fire within an interval.
let last = match self.last_fired.get(&(community_id, workflow.id)) {
Some(t) => Some(*t),
None => match self
.db
.latest_scheduled_workflow_fire(community_id, workflow.id)
.await
{
Ok(anchor) => anchor,
Err(e) => {
// Fail closed: a missing anchor reads as
// last_fired = now in interval_should_fire,
// so this tick is suppressed and the next
// tick retries. Surface the read failure so
// a persistently-unreadable anchor is visible
// rather than silently stalling the schedule.
tracing::warn!(
community_id = %community_id,
workflow_id = %workflow.id,
"Cron tick: failed to read interval restart anchor, \
suppressing this tick: {e}"
);
None
}
},
};
if !self.interval_prefilter_should_fire(
community_id,
workflow.id,
dur,
last,
now,
) {
continue;
}
match interval_fire_instant(dur, now, workflow.id) {
Some(instant) => (instant, "interval"),
None => continue,
}
}
_ => continue, // Non-schedule triggers handled by on_event()
};
// SEC-006: recheck the owner's current channel authority
// BEFORE the durable claim. Placing the gate after the claim
// would let a revoked owner's workflow consume the
// at-most-once fire slot (claims are never re-fired), turning
// revocation into a denial-of-fire for a later re-enable.
if let Err(e) = self
.check_owner_authority(community_id, channel_id, &workflow.owner_pubkey, &def)
.await
{
tracing::warn!(
workflow_id = %workflow.id,
"Cron tick: skipping workflow — owner authority check failed: {e}"
);
continue;
}
// Durable at-most-once claim — the cross-pod fire boundary.
// The loser receives `None` and skips BEFORE any run creation or
// side effect. `community_id` is the workflow row's own
// community (server provenance from the scan), never client
// input; the claim binds `(community_id, workflow_id,
// scheduled_for)` so a duplicate workflow UUID in another
// community claims independently.
match self
.db
.claim_scheduled_workflow_fire(community_id, workflow.id, scheduled_for)
.await
{
Ok(Some(_)) => {}
Ok(None) => {
// Another pod (or an earlier tick this pod) already
// claimed this instant. Still advance the in-memory
// interval clock so we don't re-attempt the claim every
// tick for the rest of the interval.
if trigger_type == "interval" {
self.last_fired.insert((community_id, workflow.id), now);
}
continue;
}
Err(e) => {
tracing::error!(
workflow_id = %workflow.id,
"Cron tick: scheduled-fire claim failed: {e}"
);
continue;
}
}
// Fix 5: handle serialization errors explicitly rather than silently
// dropping the trigger context with .ok().
let trigger_ctx = executor::TriggerContext {
channel_id: channel_id.to_string(),
timestamp: now.timestamp().to_string(),
..Default::default()
};
let trigger_ctx_json = match serde_json::to_value(&trigger_ctx) {
Ok(v) => Some(v),
Err(e) => {
tracing::error!(
workflow_id = %workflow.id,
"Cron tick: failed to serialize trigger context: {e}"
);
continue;
}
};
let run_id = match self
.db
.create_workflow_run(
community_id,
workflow.id,
None, // no trigger event for cron
trigger_ctx_json.as_ref(),
)
.await
{
Ok(id) => id,
Err(e) => {
tracing::error!(
workflow_id = %workflow.id,
"Cron tick: failed to create workflow run: {e}"
);
// The claim is held but the run failed to create. The
// claim row intentionally stays (its `workflow_run_id`
// NULL) so this instant is not re-fired: at-most-once is
// preserved over exactly-once on transient run-insert
// failures.
continue;
}
};
// Link the won claim to its run for ops/audit forensics. The
// claim row already guarantees dedupe; this is best-effort.
if let Err(e) = self
.db
.attach_scheduled_workflow_run(community_id, workflow.id, scheduled_for, run_id)
.await
{
tracing::warn!(
workflow_id = %workflow.id,
run_id = %run_id,
"Cron tick: failed to attach run to scheduled-fire claim: {e}"
);
}
// Update last_fired AFTER a successful claim+insert so that a
// failure doesn't suppress the next tick for the full interval.
// Only needed for interval triggers — cron uses window-based
// matching which already prevents double-fire within the same
// minute, and the durable claim backstops both.
if trigger_type == "interval" {
self.last_fired.insert((community_id, workflow.id), now);
}
// Fix 6: log the specific trigger type (cron vs interval).
tracing::info!(
workflow_id = %workflow.id,
run_id = %run_id,
trigger = trigger_type,
"Cron trigger fired"
);
let engine = Arc::clone(self);
let def_clone = def.clone();
let ctx_clone = trigger_ctx.clone();
tokio::spawn(async move {
let result = executor::execute_run(
&engine,
community_id,
run_id,
&def_clone,
&ctx_clone,
)
.await;
engine
.finalize_run(community_id, run_id, result, None)
.await;
});
}
// Fix 1: prune stale last_fired entries for workflows that are no longer
// active/enabled. Without this the DashMap grows monotonically as
// workflows are deleted or disabled. Keyed by `(community_id, id)` so
// entries are matched to the same scope they were inserted under.
let active_ids: std::collections::HashSet<(CommunityId, Uuid)> =
workflows.iter().map(|w| (w.community_id, w.id)).collect();
self.last_fired.retain(|key, _| active_ids.contains(key));
}
}
}
/// Find the cron schedule instant that fired within the `window_secs`-wide
/// window ending at `now`, if any.
///
/// Uses window-based matching: finds the next scheduled time after
/// `(now - window_secs)` and returns it when it falls at or before `now`.
/// This tolerates tick drift gracefully — a 61s tick won't miss a
/// minute-granularity cron expression. The returned instant is the cron's own
/// scheduled time (not `now`), so every pod evaluating the same expression in
/// the same window computes the *same* value — making it a safe, deterministic
/// claim anchor for cross-pod at-most-once firing.
///
/// Returns `None` (and logs a warning) if the expression is invalid or nothing
/// is due in the window.
fn cron_fire_instant(
expr: &str,
now: DateTime<Utc>,
window_secs: i64,
workflow_id: Uuid,
) -> Option<DateTime<Utc>> {
let normalized = schema::normalize_cron(expr);
match normalized.parse::<cron::Schedule>() {
Ok(sched) => {
let window_start = now - chrono::Duration::seconds(window_secs);
sched.after(&window_start).next().filter(|t| *t <= now)
}
Err(e) => {
tracing::warn!(
workflow_id = %workflow_id,
"Cron tick: invalid cron expression '{expr}': {e}"
);
None
}
}
}
/// Quantize `now` to the interval bucket boundary, yielding a deterministic
/// claim anchor that every pod computes identically within the same bucket.
///
/// The boundary is `floor(now / interval) * interval` from the Unix epoch.
/// Because the scheduler ticks every 60s and interval schedules are minutes or
/// longer, bounded cross-pod clock skew keeps all pods inside the same bucket,
/// so they collide on one `(community, workflow, scheduled_for)` claim — only
/// one wins and creates the run. Returns `None` if the duration is unparseable
/// or non-positive (the caller skips firing).
fn interval_fire_instant(
dur: &str,
now: DateTime<Utc>,
workflow_id: Uuid,
) -> Option<DateTime<Utc>> {
match executor::parse_duration_secs(dur) {
Ok(interval_secs) if interval_secs > 0 => {
let secs = interval_secs as i64;
let bucket = (now.timestamp().div_euclid(secs)) * secs;
DateTime::from_timestamp(bucket, 0)
}
Ok(_) => {
tracing::warn!(
workflow_id = %workflow_id,
"Cron tick: interval duration is zero — skipping"
);
None
}
Err(e) => {
tracing::warn!(
workflow_id = %workflow_id,
"Cron tick: invalid interval '{dur}': {e}"
);
None
}
}
}
/// Check whether an interval trigger should fire based on the last-fired time.
///
/// `last_fired` is `None` on the first tick after startup — in that case we
/// default to `now`, which prevents an immediate fire and waits a full interval.
///
/// Returns `false` (and logs a warning) if the duration string is invalid.
fn interval_should_fire(
dur: &str,
last_fired: Option<DateTime<Utc>>,
now: DateTime<Utc>,
workflow_id: Uuid,
) -> bool {
match executor::parse_duration_secs(dur) {
Ok(interval_secs) => {
// Default to now on first tick — prevents immediate fire after startup.
let last = last_fired.unwrap_or(now);
let elapsed = (now - last).num_seconds().unsigned_abs();
elapsed >= interval_secs
}
Err(e) => {
tracing::warn!(
workflow_id = %workflow_id,
"Cron tick: invalid interval '{dur}': {e}"
);
false
}
}
}
/// Interval prefilter decision + cold-start anchor seed. See the
/// [`WorkflowEngine::interval_prefilter_should_fire`] wrapper for the liveness
/// rationale. Free function over the `last_fired` map so it is unit-testable
/// without a `Db`/Postgres: the only state it touches is the in-memory anchor.
///
/// Returns `true` to fire, `false` to suppress. On the cold-start `None` suppress
/// path it seeds `now` so the next tick has a real anchor; it never advances an
/// existing (`Some`) anchor, which is mid-interval and must elapse on its own.
fn interval_prefilter_should_fire(
last_fired: &DashMap<(CommunityId, Uuid), DateTime<Utc>>,
community_id: CommunityId,
workflow_id: Uuid,
dur: &str,
last: Option<DateTime<Utc>>,
now: DateTime<Utc>,
) -> bool {
if interval_should_fire(dur, last, now, workflow_id) {
return true;
}
if last.is_none() {
last_fired.insert((community_id, workflow_id), now);
}
false
}
/// Check emoji and filter-expression conditions that determine whether a
/// matched workflow should actually fire. Extracted from `on_event` to keep
/// the per-workflow loop body small.
///
/// Returns `true` if the workflow should fire, `false` to skip.
async fn should_fire_workflow(
def: &WorkflowDef,
trigger_ctx: &executor::TriggerContext,
workflow_id: uuid::Uuid,
) -> bool {
if let TriggerDef::ReactionAdded {
emoji: Some(ref expected),
..
} = def.trigger
{
if &trigger_ctx.emoji != expected {
tracing::debug!(
workflow_id = %workflow_id,
expected_emoji = %expected,
actual_emoji = %trigger_ctx.emoji,
"Reaction emoji mismatch — skipping workflow"
);
return false;
}
}
let filter = match &def.trigger {
TriggerDef::MessagePosted { filter }
| TriggerDef::ReactionAdded { filter, .. }
| TriggerDef::DiffPosted { filter } => filter.as_ref(),
TriggerDef::Schedule { .. } | TriggerDef::Webhook => None,
};
if let Some(expr) = filter {
match executor::evaluate_condition(expr, trigger_ctx, &HashMap::new()).await {
Ok(true) => {}
Ok(false) => {
tracing::debug!(
workflow_id = %workflow_id,
"Trigger filter evaluated false — skipping workflow"
);
return false;
}
Err(e) => {
tracing::warn!(
workflow_id = %workflow_id,
"Trigger filter error: {e} — skipping workflow"
);
return false;
}
}
}
true
}
/// Build a [`executor::TriggerContext`] from a [`buzz_core::StoredEvent`].
///
/// - `text` — event content (message body or reaction emoji character)
/// - `author` — pubkey hex string
/// - `channel_id` — channel UUID as string (empty if no channel scope)
/// - `timestamp` — Unix timestamp as string
/// - `emoji` — for `KIND_REACTION` events, the content is the emoji; otherwise empty
/// - `message_id` — for reactions, the target message's event ID (from `e` tag);
/// for all other events, the event's own ID
pub fn build_trigger_context(event: &buzz_core::StoredEvent) -> executor::TriggerContext {
let kind_u32 = event_kind_u32(&event.event);
let content = event.event.content.clone();
// Workflow conditions make authorization decisions from `trigger_author`,
// so it must come from the event signature. An `actor` tag is ordinary
// signer-controlled metadata and cannot speak for another pubkey.
let author = event.event.pubkey.to_hex();
// For reaction events (NIP-25), the content field holds the emoji character
// or shortcode (e.g. "👍", "+", "-"). Expose it as `emoji`.
let emoji = if kind_u32 == KIND_REACTION {
content.clone()
} else {
String::new()
};
// For reactions (NIP-25), `message_id` should be the target message, not
// the reaction event itself. NIP-25 stores the target in an `e` tag whose
// value is a 64-char hex event ID (not a UUID channel reference).
// Per NIP-25, the last `e` tag is the direct target (earlier ones may be thread roots).
let message_id = if kind_u32 == KIND_REACTION {
event
.event
.tags
.iter()
.rev()
.find_map(|tag| {
let key = tag.kind().to_string();
if key == "e" {
tag.content().and_then(|v| {
// Distinguish hex event IDs (64 chars) from UUID channel refs.
if v.len() == 64 && v.chars().all(|c| c.is_ascii_hexdigit()) {
Some(v.to_string())
} else {
None
}
})
} else {
None
}
})
// Fallback to the reaction event's own ID if no valid `e` tag found.
.unwrap_or_else(|| event.event.id.to_hex())
} else {
event.event.id.to_hex()
};
executor::TriggerContext {
text: content,
author,
channel_id: event
.channel_id
.map(|id| id.to_string())
.unwrap_or_default(),
timestamp: event.event.created_at.as_secs().to_string(),
emoji,
message_id,
is_reply: event_is_reply(&event.event),
webhook_fields: HashMap::new(),
}
}
/// True when an event is a threaded reply — it carries a valid NIP-10 `reply`
/// marker. Delegates to the shared [`buzz_core::nip10`] parser so this stays in
/// lockstep with ingest's `resolve_nip10_thread_meta`: a `root` marker alone is
/// top-level, and a marker with a malformed (non-64-hex) event id is ignored by
/// ingest, so it must not flip `trigger_is_reply` either — else a
/// `trigger_is_reply == false` workflow would skip a message ingest stored as a
/// new top-level post.
fn event_is_reply(event: &nostr::Event) -> bool {
buzz_core::nip10::parse_thread_markers(&event.tags)
.reply
.is_some()
}
/// Pure authority decision for [`WorkflowEngine::check_owner_authority`].
///
/// `role` is the owner's *current* active role in the workflow's channel
/// (`None` = not an active member — removed, left, or never joined).
/// `needs_elevated` is true when the definition contains an
/// exfiltration-capable action (see `WorkflowDef::requires_elevated_authority`).
///
/// Rules:
/// - not a member ⇒ deny, always;
/// - a role that cannot write (`guest`, or a role this build does not
/// know) ⇒ deny: a run acts as its owner, and a guest cannot write;
/// - member or bot ⇒ allowed for ordinary definitions;
/// - elevated definitions ⇒ only `owner` / `admin` roles.
fn owner_authority_allows(role: Option<&str>, needs_elevated: bool) -> bool {
let Some(role) = role.and_then(|r| r.parse::<buzz_core::channel::MemberRole>().ok()) else {
return false;
};
if needs_elevated {
role.is_elevated()
} else {
role.can_post()
}
}
/// Returns `true` if the trigger type matches the given event kind.
fn trigger_matches_event(trigger: &TriggerDef, kind_u32: u32) -> bool {
use buzz_core::kind::{KIND_REACTION, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF};
match trigger {
TriggerDef::MessagePosted { .. } => kind_u32 == KIND_STREAM_MESSAGE,
TriggerDef::ReactionAdded { .. } => kind_u32 == KIND_REACTION,
TriggerDef::DiffPosted { .. } => kind_u32 == KIND_STREAM_MESSAGE_DIFF,
// Schedule and Webhook triggers are not fired by channel events.
TriggerDef::Schedule { .. } | TriggerDef::Webhook => false,
}
}
#[cfg(test)]
mod postgres_tests {
use super::*;
#[test]
fn cron_fire_instant_matches_within_window() {
// "every minute" cron — should always fire within a 60s window.
let now = chrono::DateTime::parse_from_rfc3339("2026-06-15T12:00:30Z")
.unwrap()
.with_timezone(&Utc);
let wf_id = Uuid::new_v4();
// The matched instant is the minute boundary 12:00:00, NOT `now`.
assert_eq!(
cron_fire_instant("* * * * *", now, 60, wf_id),
Some(
chrono::DateTime::parse_from_rfc3339("2026-06-15T12:00:00Z")
.unwrap()
.with_timezone(&Utc)
),
"every-minute cron should return the minute boundary as the anchor"
);
}
#[test]
fn cron_fire_instant_returns_none_for_invalid_expr() {
let now = Utc::now();
let wf_id = Uuid::new_v4();
assert!(
cron_fire_instant("not-a-cron", now, 60, wf_id).is_none(),
"invalid cron should return None"
);
}
#[test]
fn cron_fire_instant_returns_none_outside_window() {
// Fixed time: 2026-06-15 14:30:00 UTC (a Sunday in June)
let now = chrono::DateTime::parse_from_rfc3339("2026-06-15T14:30:00Z")
.unwrap()
.with_timezone(&Utc);
let wf_id = Uuid::new_v4();
// "0 0 1 1 *" = midnight on Jan 1 only — June 15 is definitely outside.
assert!(
cron_fire_instant("0 0 1 1 *", now, 60, wf_id).is_none(),
"Jan-1-only cron should not fire on June 15"
);
}
#[test]
fn cron_fire_instant_at_exact_minute_boundary() {
// Fixed time: exactly 09:00:00 UTC. Cron "0 9 * * *" fires at 09:00.
// Window [08:59:00, 09:00:00] should contain the fire time.
let now = chrono::DateTime::parse_from_rfc3339("2026-06-15T09:00:00Z")
.unwrap()
.with_timezone(&Utc);
let wf_id = Uuid::new_v4();
assert_eq!(
cron_fire_instant("0 9 * * *", now, 60, wf_id),
Some(now),
"cron should fire at exact minute boundary, anchored on 09:00:00"
);
}
#[test]
fn cron_fire_instant_within_drift_window_anchors_on_scheduled_time() {
// Fixed time: 09:00:45 UTC (45s drift). Cron "0 9 * * *" fires at 09:00.
// Window [08:59:45, 09:00:45] should still contain 09:00:00. Critically,
// the anchor is the *scheduled* 09:00:00 — not the drifted `now` — so a
// second pod ticking at 09:00:50 computes the identical claim key.
let now = chrono::DateTime::parse_from_rfc3339("2026-06-15T09:00:45Z")
.unwrap()
.with_timezone(&Utc);
let wf_id = Uuid::new_v4();
assert_eq!(
cron_fire_instant("0 9 * * *", now, 60, wf_id),
Some(
chrono::DateTime::parse_from_rfc3339("2026-06-15T09:00:00Z")
.unwrap()
.with_timezone(&Utc)
),
"cron anchor must be the scheduled instant, stable across pod tick drift"
);
}
#[test]
fn cron_fire_instant_returns_none_just_outside_window() {
// Fixed time: 09:01:01 UTC. Cron "0 9 * * *" fires at 09:00:00.
// Window [09:00:01, 09:01:01] does NOT contain 09:00:00.
let now = chrono::DateTime::parse_from_rfc3339("2026-06-15T09:01:01Z")
.unwrap()
.with_timezone(&Utc);
let wf_id = Uuid::new_v4();
assert!(
cron_fire_instant("0 9 * * *", now, 60, wf_id).is_none(),
"cron should not fire 61s after the scheduled time"
);
}
#[test]
fn interval_fire_instant_quantizes_to_bucket_boundary() {
// Two pods ticking at different sub-interval offsets must compute the
// *same* bucket boundary so they collide on one claim. 1h interval,
// epoch-aligned: 12:34:56 and 12:59:01 both floor to 12:00:00.
let wf_id = Uuid::new_v4();
let a = chrono::DateTime::parse_from_rfc3339("2026-06-15T12:34:56Z")
.unwrap()
.with_timezone(&Utc);
let b = chrono::DateTime::parse_from_rfc3339("2026-06-15T12:59:01Z")
.unwrap()
.with_timezone(&Utc);
let bucket = chrono::DateTime::parse_from_rfc3339("2026-06-15T12:00:00Z")
.unwrap()
.with_timezone(&Utc);
assert_eq!(interval_fire_instant("1h", a, wf_id), Some(bucket));
assert_eq!(interval_fire_instant("1h", b, wf_id), Some(bucket));
// Next hour is a distinct bucket.
let c = chrono::DateTime::parse_from_rfc3339("2026-06-15T13:00:10Z")
.unwrap()
.with_timezone(&Utc);
let next_bucket = chrono::DateTime::parse_from_rfc3339("2026-06-15T13:00:00Z")
.unwrap()
.with_timezone(&Utc);
assert_eq!(interval_fire_instant("1h", c, wf_id), Some(next_bucket));
}
#[test]
fn interval_fire_instant_returns_none_for_invalid_duration() {
let now = Utc::now();
let wf_id = Uuid::new_v4();
assert!(interval_fire_instant("not-a-duration", now, wf_id).is_none());
}
#[test]
fn interval_should_fire_returns_false_on_first_tick() {
// When last_fired is None (first tick), defaults to now → elapsed = 0 → false.
let now = Utc::now();
let wf_id = Uuid::new_v4();
assert!(
!interval_should_fire("1h", None, now, wf_id),
"first tick should not fire immediately"
);
}
#[test]
fn interval_should_fire_returns_true_after_interval_elapsed() {
let wf_id = Uuid::new_v4();
let now = Utc::now();
// last_fired was 2 hours ago; interval is 1h → should fire.
let last = now - chrono::Duration::hours(2);
assert!(
interval_should_fire("1h", Some(last), now, wf_id),
"should fire after interval elapsed"
);
}
#[test]
fn interval_should_fire_returns_false_before_interval_elapsed() {
let wf_id = Uuid::new_v4();
let now = Utc::now();
// last_fired was 30 minutes ago; interval is 1h → should not fire.
let last = now - chrono::Duration::minutes(30);
assert!(
!interval_should_fire("1h", Some(last), now, wf_id),
"should not fire before interval elapsed"
);
}
#[test]
fn interval_should_fire_returns_false_for_invalid_duration() {
let now = Utc::now();
let wf_id = Uuid::new_v4();
assert!(
!interval_should_fire("not-a-duration", None, now, wf_id),
"invalid duration should return false"
);
}
#[test]
fn interval_should_fire_at_exact_boundary() {
let wf_id = Uuid::new_v4();
let now = Utc::now();
// last_fired was exactly 1 hour ago; interval is 1h → should fire (elapsed >= interval).
let last = now - chrono::Duration::hours(1);
assert!(
interval_should_fire("1h", Some(last), now, wf_id),
"should fire at exact interval boundary"
);
}
// ── Interval cold-start liveness (Max's blocker on the scheduled lane) ──
// A brand-new interval workflow has no in-memory anchor and no prior durable
// claim, so the prefilter resolves `last = None`. Without seeding, every tick
// reads `None`, suppresses, and writes nothing — the workflow never fires.
// `interval_prefilter_should_fire` must seed `now` on that first suppress so a
// real anchor exists for the next tick.
#[test]
fn interval_cold_start_seeds_anchor_then_fires_after_one_interval() {
let map: DashMap<(CommunityId, Uuid), DateTime<Utc>> = DashMap::new();
let community = CommunityId::from_uuid(Uuid::new_v4());
let wf = Uuid::new_v4();
let t0 = Utc::now();
// Tick 1 (cold start): no in-memory entry, DB anchor is None → last = None.
let fired_1 = interval_prefilter_should_fire(&map, community, wf, "1h", None, t0);
assert!(!fired_1, "first tick must suppress (wait a full interval)");
let seeded = map.get(&(community, wf)).map(|v| *v);
assert_eq!(
seeded,
Some(t0),
"first suppressed tick must seed the anchor to `now`, else it suppresses forever"
);
// Tick 2, mid-interval: caller now passes the seeded anchor as `last`.
let t1 = t0 + chrono::Duration::minutes(30);
let last = map.get(&(community, wf)).map(|v| *v);
let fired_2 = interval_prefilter_should_fire(&map, community, wf, "1h", last, t1);
assert!(!fired_2, "still mid-interval → suppress");
assert_eq!(
map.get(&(community, wf)).map(|v| *v),
Some(t0),
"mid-interval suppress must NOT advance the anchor (or it would never elapse)"
);
// Tick 3, one interval elapsed → fire.
let t2 = t0 + chrono::Duration::hours(1);
let last = map.get(&(community, wf)).map(|v| *v);
let fired_3 = interval_prefilter_should_fire(&map, community, wf, "1h", last, t2);
assert!(
fired_3,
"after one full interval the cold-started workflow must fire"
);
}
#[test]
fn interval_prefilter_does_not_advance_existing_anchor_on_suppress() {
// Regression for the inverse bug: if a `Some` anchor were re-seeded to
// `now` on every suppressed tick, the interval would never elapse.
let map: DashMap<(CommunityId, Uuid), DateTime<Utc>> = DashMap::new();
let community = CommunityId::from_uuid(Uuid::new_v4());
let wf = Uuid::new_v4();
let now = Utc::now();
let anchor = now - chrono::Duration::minutes(10); // 10m into a 1h interval
map.insert((community, wf), anchor);
let fired = interval_prefilter_should_fire(&map, community, wf, "1h", Some(anchor), now);
assert!(!fired, "mid-interval suppress");
assert_eq!(
map.get(&(community, wf)).map(|v| *v),
Some(anchor),
"existing anchor must be preserved exactly, not advanced to now"
);
}
#[test]
fn interval_prefilter_passes_through_a_due_fire_without_touching_anchor() {
// When the interval has elapsed the prefilter returns true and leaves the
// anchor to the post-claim update path (which writes `now` only on a won
// claim), so the prefilter must not seed here.
let map: DashMap<(CommunityId, Uuid), DateTime<Utc>> = DashMap::new();
let community = CommunityId::from_uuid(Uuid::new_v4());
let wf = Uuid::new_v4();
let now = Utc::now();
let anchor = now - chrono::Duration::hours(2); // overdue on a 1h interval
let fired = interval_prefilter_should_fire(&map, community, wf, "1h", Some(anchor), now);
assert!(fired, "overdue interval must fire");
assert!(
map.get(&(community, wf)).is_none(),
"a firing tick must not seed via the prefilter; the post-claim path owns the write"
);
}
#[test]
fn workflow_config_defaults() {
let cfg = WorkflowConfig::default();
assert_eq!(cfg.max_concurrent, 100);
assert_eq!(cfg.default_timeout_secs, 300);
}
#[test]
fn parse_yaml_roundtrip() {
let yaml = r#"
name: "Test Workflow"
trigger:
on: message_posted
steps:
- id: s1
action: send_message
text: "Hello {{trigger.author}}"
"#;
let (def, json) = WorkflowEngine::parse_yaml(yaml).expect("parse failed");
assert_eq!(def.name, "Test Workflow");
let reparsed: WorkflowDef = serde_json::from_str(&json).expect("json round-trip");
assert_eq!(reparsed.name, def.name);
assert_eq!(reparsed.steps.len(), 1);
}
#[test]
fn trigger_matches_stream_message() {
let trigger = TriggerDef::MessagePosted { filter: None };
assert!(trigger_matches_event(
&trigger,
buzz_core::kind::KIND_STREAM_MESSAGE
));
assert!(!trigger_matches_event(
&trigger,
buzz_core::kind::KIND_REACTION
));
}
#[test]
fn trigger_matches_reaction() {
let trigger = TriggerDef::ReactionAdded {
emoji: None,
filter: None,
};
assert!(trigger_matches_event(
&trigger,
buzz_core::kind::KIND_REACTION
));
assert!(!trigger_matches_event(
&trigger,
buzz_core::kind::KIND_STREAM_MESSAGE
));
}
#[tokio::test]
async fn reaction_filter_matches_target_message() {
let yaml = r#"
name: "React to one message"
trigger:
on: reaction_added
filter: 'trigger_message_id == "target-message"'
steps:
- id: wait
action: delay
duration: 1s
"#;
let (def, _) = WorkflowEngine::parse_yaml(yaml).expect("parse failed");
let mut trigger_ctx = executor::TriggerContext {
message_id: "target-message".to_owned(),
..Default::default()
};
assert!(
should_fire_workflow(&def, &trigger_ctx, Uuid::new_v4()).await,
"reaction to the selected message should fire"
);
trigger_ctx.message_id = "different-message".to_owned();
assert!(
!should_fire_workflow(&def, &trigger_ctx, Uuid::new_v4()).await,
"reaction to a different message should be filtered out"
);
}
#[test]
fn schedule_trigger_never_matches_events() {
let trigger = TriggerDef::Schedule {
cron: Some("0 9 * * 1-5".to_owned()),
interval: None,
};
// Schedule triggers are fired by the cron loop, not by events.
assert!(!trigger_matches_event(
&trigger,
buzz_core::kind::KIND_STREAM_MESSAGE
));
assert!(!trigger_matches_event(
&trigger,
buzz_core::kind::KIND_REACTION
));
assert!(!trigger_matches_event(
&trigger,
buzz_core::kind::KIND_WORKFLOW_TRIGGERED
));
}
#[test]
fn webhook_trigger_never_matches_events() {
let trigger = TriggerDef::Webhook;
assert!(!trigger_matches_event(
&trigger,
buzz_core::kind::KIND_STREAM_MESSAGE
));
assert!(!trigger_matches_event(&trigger, 0));
}
#[test]
fn message_posted_matches_kind_9_only() {
let trigger = TriggerDef::MessagePosted { filter: None };
// Must match KIND_STREAM_MESSAGE = 9.
assert!(trigger_matches_event(&trigger, 9));
// Must NOT match reaction (kind 7).
assert!(!trigger_matches_event(&trigger, 7));
// Must NOT match forum post (kind 45001).
assert!(!trigger_matches_event(&trigger, 45001));
// Must NOT match stream message v2 (kind 40002).
assert!(!trigger_matches_event(&trigger, 40002));
}
#[test]
fn reaction_added_matches_kind_7_only() {
let trigger = TriggerDef::ReactionAdded {
emoji: None,
filter: None,
};
// Must match KIND_REACTION = 7.
assert!(trigger_matches_event(&trigger, 7));
// Must NOT match stream message (kind 9).
assert!(!trigger_matches_event(&trigger, 9));
// Must NOT match forum post (kind 45001).
assert!(!trigger_matches_event(&trigger, 45001));
}
#[test]
fn reaction_added_with_emoji_filter_still_matches_kind_7() {
// The emoji filter is evaluated at execution time, not trigger-matching time.
// trigger_matches_event only checks the kind number.
let trigger = TriggerDef::ReactionAdded {
emoji: Some("thumbsup".to_owned()),
filter: None,
};
assert!(trigger_matches_event(&trigger, 7));
assert!(!trigger_matches_event(&trigger, 9));
}
#[test]
fn message_posted_with_filter_still_matches_kind_9() {
// The filter expression is evaluated at execution time, not trigger-matching time.
let trigger = TriggerDef::MessagePosted {
filter: Some("str_contains(trigger_text, \"P1\")".to_owned()),
};
assert!(trigger_matches_event(&trigger, 9));
assert!(!trigger_matches_event(&trigger, 7));
}
#[test]
fn workflow_execution_kinds_do_not_match_any_trigger() {
// Workflow execution events (46001–46012) must never match triggers
// to prevent infinite loops. The on_event() method filters these out
// before calling trigger_matches_event, but verify the function itself
// also returns false for these kinds.
let msg_trigger = TriggerDef::MessagePosted { filter: None };
let react_trigger = TriggerDef::ReactionAdded {
emoji: None,
filter: None,
};
for kind in buzz_core::kind::KIND_WORKFLOW_TRIGGERED
..=buzz_core::kind::KIND_WORKFLOW_APPROVAL_DENIED
{
assert!(
!trigger_matches_event(&msg_trigger, kind),
"message_posted should not match workflow execution kind {kind}"
);
assert!(
!trigger_matches_event(&react_trigger, kind),
"reaction_added should not match workflow execution kind {kind}"
);
}
}
#[test]
fn trigger_matches_event_kind_zero_matches_nothing() {
// Kind 0 is a profile event — no trigger should match it.
let msg_trigger = TriggerDef::MessagePosted { filter: None };
let react_trigger = TriggerDef::ReactionAdded {
emoji: None,
filter: None,
};
let sched_trigger = TriggerDef::Schedule {
cron: None,
interval: Some("1h".to_owned()),
};
let webhook_trigger = TriggerDef::Webhook;
assert!(!trigger_matches_event(&msg_trigger, 0));
assert!(!trigger_matches_event(&react_trigger, 0));
assert!(!trigger_matches_event(&sched_trigger, 0));
assert!(!trigger_matches_event(&webhook_trigger, 0));
}
#[test]
fn diff_posted_matches_kind_40008_only() {
let trigger = TriggerDef::DiffPosted { filter: None };
assert!(trigger_matches_event(&trigger, 40008));
assert!(!trigger_matches_event(&trigger, 9));
assert!(!trigger_matches_event(&trigger, 7));
}
#[test]
fn message_posted_does_not_match_kind_40008() {
let trigger = TriggerDef::MessagePosted { filter: None };
assert!(!trigger_matches_event(&trigger, 40008));
assert!(trigger_matches_event(&trigger, 9));
}
#[test]
fn workflow_config_custom_values() {
let cfg = WorkflowConfig {
max_concurrent: 50,
default_timeout_secs: 600,
};
assert_eq!(cfg.max_concurrent, 50);
assert_eq!(cfg.default_timeout_secs, 600);
}
fn make_message_event() -> buzz_core::StoredEvent {
use nostr::{EventBuilder, Keys, Kind};
use uuid::Uuid;
let keys = Keys::generate();
let event = EventBuilder::new(Kind::Custom(9), "hello world")
.tags([])
.sign_with_keys(&keys)
.expect("sign");
buzz_core::StoredEvent::new(event, Some(Uuid::new_v4()))
}
/// Create a reaction event with an `e` tag pointing to a target message.
fn make_reaction_event() -> (buzz_core::StoredEvent, String) {
use nostr::{EventBuilder, Keys, Kind, Tag};
use uuid::Uuid;
let keys = Keys::generate();
// Create a dummy target message ID (64-char hex).
let target_keys = Keys::generate();
let target_event = EventBuilder::new(Kind::Custom(9), "target msg")
.tags([])
.sign_with_keys(&target_keys)
.expect("sign target");
let target_id_hex = target_event.id.to_hex();
// NIP-25: reaction references the target via an `e` tag.
let e_tag = Tag::parse(["e", &target_id_hex]).expect("tag parse");
let event = EventBuilder::new(Kind::Reaction, "👍")
.tags([e_tag])
.sign_with_keys(&keys)
.expect("sign");
(
buzz_core::StoredEvent::new(event, Some(Uuid::new_v4())),
target_id_hex,
)
}
#[test]
fn build_trigger_context_message_event() {
let stored = make_message_event();
let ctx = build_trigger_context(&stored);
assert_eq!(ctx.text, "hello world");
assert_eq!(ctx.author, stored.event.pubkey.to_hex());
assert_eq!(ctx.channel_id, stored.channel_id.unwrap().to_string());
assert_eq!(ctx.timestamp, stored.event.created_at.as_secs().to_string());
assert_eq!(ctx.message_id, stored.event.id.to_hex());
// Non-reaction events have empty emoji.
assert_eq!(ctx.emoji, "");
assert!(ctx.webhook_fields.is_empty());
// A top-level message (no e-tags) is not a reply.
assert!(!ctx.is_reply);
}
#[test]
fn build_trigger_context_is_reply_true_for_threaded_message() {
use nostr::{EventBuilder, Keys, Kind, Tag};
use uuid::Uuid;
let root = Keys::generate();
let root_event = EventBuilder::new(Kind::Custom(9), "root")
.tags([])
.sign_with_keys(&root)
.expect("sign root");
let root_hex = root_event.id.to_hex();
let keys = Keys::generate();
let event = EventBuilder::new(Kind::Custom(9), "a threaded reply")
.tags([
Tag::parse(["e", &root_hex, "", "root"]).expect("root tag"),
Tag::parse(["e", &root_hex, "", "reply"]).expect("reply tag"),
])
.sign_with_keys(&keys)
.expect("sign");
let stored = buzz_core::StoredEvent::new(event, Some(Uuid::new_v4()));
let ctx = build_trigger_context(&stored);
assert!(ctx.is_reply, "message with reply/root e-tags is a reply");
}
#[test]
fn build_trigger_context_is_reply_true_for_reply_only_marker() {
// A NIP-10 `reply` marker without a `root` marker (the fallback ingest
// treats as `root == reply`) is still a threaded reply.
use nostr::{EventBuilder, Keys, Kind, Tag};
use uuid::Uuid;
let parent = Keys::generate();
let parent_event = EventBuilder::new(Kind::Custom(9), "parent")
.sign_with_keys(&parent)
.expect("sign parent");
let keys = Keys::generate();
let event = EventBuilder::new(Kind::Custom(9), "reply only")
.tags([Tag::parse(["e", &parent_event.id.to_hex(), "", "reply"]).expect("reply tag")])
.sign_with_keys(&keys)
.expect("sign");
let stored = buzz_core::StoredEvent::new(event, Some(Uuid::new_v4()));
let ctx = build_trigger_context(&stored);
assert!(ctx.is_reply, "a lone `reply` marker is a reply");
}
#[test]
fn build_trigger_context_is_reply_false_for_root_only_marker() {
// Ingest treats `(root=Some, reply=None)` as top-level, so
// `event_is_reply` must too — otherwise `trigger_is_reply == false`
// would skip a message the relay stored as a new top-level post.
use nostr::{EventBuilder, Keys, Kind, Tag};
use uuid::Uuid;
let root = Keys::generate();
let root_event = EventBuilder::new(Kind::Custom(9), "root")
.sign_with_keys(&root)
.expect("sign root");
let keys = Keys::generate();
let event = EventBuilder::new(Kind::Custom(9), "root marker only")
.tags([Tag::parse(["e", &root_event.id.to_hex(), "", "root"]).expect("root tag")])
.sign_with_keys(&keys)
.expect("sign");
let stored = buzz_core::StoredEvent::new(event, Some(Uuid::new_v4()));
let ctx = build_trigger_context(&stored);
assert!(
!ctx.is_reply,
"a lone `root` marker is top-level to ingest, not a reply"
);
}
#[test]
fn build_trigger_context_is_reply_false_for_unmarked_e_tag() {
// A bare `e` tag with no NIP-10 marker (e.g. a plain mention/quote) is
// not treated as a thread reply — only `reply`/`root` markers count.
use nostr::{EventBuilder, Keys, Kind, Tag};
use uuid::Uuid;
let other = Keys::generate();
let other_event = EventBuilder::new(Kind::Custom(9), "other")
.tags([])
.sign_with_keys(&other)
.expect("sign");
let keys = Keys::generate();
let event = EventBuilder::new(Kind::Custom(9), "quotes another")
.tags([Tag::parse(["e", &other_event.id.to_hex()]).expect("bare e tag")])
.sign_with_keys(&keys)
.expect("sign");
let stored = buzz_core::StoredEvent::new(event, Some(Uuid::new_v4()));
let ctx = build_trigger_context(&stored);
assert!(!ctx.is_reply, "unmarked e-tag must not count as a reply");
}
#[test]
fn build_trigger_context_is_reply_false_for_malformed_reply_id() {
// Ingest gates a marker on a valid 64-hex event id; a malformed reply
// id is not a thread link, so ingest stores the event top-level. The
// predicate must agree, or `trigger_is_reply == false` would skip it.
use nostr::{EventBuilder, Keys, Kind, Tag};
use uuid::Uuid;
let keys = Keys::generate();
let event = EventBuilder::new(Kind::Custom(9), "malformed reply marker")
.tags([Tag::parse(["e", "bad", "", "reply"]).expect("reply tag")])
.sign_with_keys(&keys)
.expect("sign");
let stored = buzz_core::StoredEvent::new(event, Some(Uuid::new_v4()));
let ctx = build_trigger_context(&stored);
assert!(
!ctx.is_reply,
"a malformed reply id is ignored by ingest, so it is top-level"
);
}
#[test]
fn build_trigger_context_is_reply_false_for_valid_root_malformed_reply() {
// A valid `root` marker but a malformed `reply` id: ingest ignores the
// reply and stores the event as root-only, i.e. top-level. The predicate
// must not flip to reply on the malformed marker.
use nostr::{EventBuilder, Keys, Kind, Tag};
use uuid::Uuid;
let root = Keys::generate();
let root_event = EventBuilder::new(Kind::Custom(9), "root")
.sign_with_keys(&root)
.expect("sign root");
let keys = Keys::generate();
let event = EventBuilder::new(Kind::Custom(9), "valid root, malformed reply")
.tags([
Tag::parse(["e", &root_event.id.to_hex(), "", "root"]).expect("root tag"),
Tag::parse(["e", "bad", "", "reply"]).expect("reply tag"),
])
.sign_with_keys(&keys)
.expect("sign");
let stored = buzz_core::StoredEvent::new(event, Some(Uuid::new_v4()));
let ctx = build_trigger_context(&stored);
assert!(
!ctx.is_reply,
"a valid root with a malformed reply id is top-level to ingest"
);
}
#[test]
fn build_trigger_context_reaction_event() {
let (stored, target_id_hex) = make_reaction_event();
let ctx = build_trigger_context(&stored);
// For reactions, content IS the emoji.
assert_eq!(ctx.text, "👍");
assert_eq!(ctx.emoji, "👍");
assert_eq!(ctx.author, stored.event.pubkey.to_hex());
// message_id should be the TARGET message, not the reaction event itself.
assert_eq!(ctx.message_id, target_id_hex);
assert_ne!(ctx.message_id, stored.event.id.to_hex());
assert!(ctx.webhook_fields.is_empty());
}
#[test]
fn build_trigger_context_no_channel_id() {
use nostr::{EventBuilder, Keys, Kind};
let keys = Keys::generate();
let event = EventBuilder::new(Kind::Custom(9), "msg")
.tags([])
.sign_with_keys(&keys)
.expect("sign");
// channel_id = None (global/DM event)
let stored = buzz_core::StoredEvent::new(event, None);
let ctx = build_trigger_context(&stored);
assert_eq!(ctx.channel_id, "");
assert_eq!(ctx.text, "msg");
}
#[test]
fn build_trigger_context_author_is_hex_pubkey() {
let stored = make_message_event();
let ctx = build_trigger_context(&stored);
// Pubkey hex is 64 lowercase hex characters.
assert_eq!(ctx.author.len(), 64);
assert!(ctx.author.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn build_trigger_context_ignores_actor_tag() {
use nostr::{EventBuilder, Keys, Kind, Tag};
let signer = Keys::generate();
let impersonated = Keys::generate();
let event = EventBuilder::new(Kind::Custom(9), "forged actor")
.tags([Tag::parse(["actor", &impersonated.public_key().to_hex()]).expect("actor tag")])
.sign_with_keys(&signer)
.expect("sign");
let stored = buzz_core::StoredEvent::new(event, Some(uuid::Uuid::new_v4()));
let ctx = build_trigger_context(&stored);
assert_eq!(ctx.author, signer.public_key().to_hex());
assert_ne!(ctx.author, impersonated.public_key().to_hex());
}
#[test]
fn build_trigger_context_message_id_is_hex() {
let stored = make_message_event();
let ctx = build_trigger_context(&stored);
// Event ID hex is 64 lowercase hex characters.
assert_eq!(ctx.message_id.len(), 64);
assert!(ctx.message_id.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn build_trigger_context_timestamp_is_numeric_string() {
let stored = make_message_event();
let ctx = build_trigger_context(&stored);
// Timestamp must parse as a u64.
ctx.timestamp
.parse::<u64>()
.expect("timestamp should be a u64 string");
}
#[test]
fn test_build_trigger_context_reaction_multiple_e_tags() {
// NIP-25: last e tag is the direct target, first may be thread root
use nostr::{EventBuilder, EventId, Keys, Kind, Tag};
use uuid::Uuid;
let keys = Keys::generate();
let thread_root_id = EventId::all_zeros();
let direct_target_id = EventId::from_byte_array([0x42; 32]);
let event = EventBuilder::new(Kind::Reaction, "👍")
.tags([
Tag::parse(["e", &thread_root_id.to_hex()]).unwrap(),
Tag::parse(["e", &direct_target_id.to_hex()]).unwrap(),
])
.sign_with_keys(&keys)
.expect("sign");
let stored = buzz_core::StoredEvent::new(event, Some(Uuid::new_v4()));
let ctx = build_trigger_context(&stored);
// Should pick the LAST e tag (direct target), not the first (thread root)
assert_eq!(ctx.message_id, direct_target_id.to_hex());
}
// -- SEC-006: owner authority decision --------------------------------
#[test]
fn owner_authority_denies_non_members_always() {
assert!(!owner_authority_allows(None, false));
assert!(!owner_authority_allows(None, true));
}
#[test]
fn owner_authority_allows_any_member_for_ordinary_definitions() {
assert!(owner_authority_allows(Some("member"), false));
assert!(owner_authority_allows(Some("bot"), false));
assert!(owner_authority_allows(Some("admin"), false));
assert!(owner_authority_allows(Some("owner"), false));
}
#[test]
fn owner_authority_denies_roles_that_cannot_write() {
for elevated in [false, true] {
assert!(!owner_authority_allows(None, elevated));
assert!(!owner_authority_allows(Some("guest"), elevated));
assert!(!owner_authority_allows(Some("superuser"), elevated));
}
}
#[test]
fn owner_authority_requires_elevated_role_for_exfiltration_definitions() {
assert!(!owner_authority_allows(Some("member"), true));
assert!(owner_authority_allows(Some("admin"), true));
assert!(owner_authority_allows(Some("owner"), true));
}
#[test]
fn requires_elevated_authority_detects_call_webhook() {
let (plain, _) = WorkflowEngine::parse_yaml(concat!(
"name: plain\n",
"trigger:\n on: message_posted\n",
"steps:\n - id: s1\n action: send_message\n text: hi\n",
))
.expect("parse plain");
assert!(!plain.requires_elevated_authority());
let (hook, _) = WorkflowEngine::parse_yaml(concat!(
"name: hook\n",
"trigger:\n on: message_posted\n",
"steps:\n - id: s1\n action: send_message\n text: hi\n",
" - id: s2\n action: call_webhook\n url: https://example.com/x\n",
))
.expect("parse hook");
assert!(hook.requires_elevated_authority());
}
// -- SEC-006: event-path regression (requires Postgres) ----------------
async fn setup_db() -> buzz_db::Db {
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
// Local-only test default; this is not a production credential.
.unwrap_or_else(|_| {
let local_test_database = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1
local_test_database.to_owned()
});
buzz_db::Db::new(&buzz_db::DbConfig {
database_url,
..Default::default()
})
.await
.expect("connect test DB")
}
/// Create a community, a channel owned by `creator`, and add `member` as a
/// plain member. Returns `(community, channel)`.
async fn setup_channel(db: &buzz_db::Db, creator: &[u8], member: &[u8]) -> (CommunityId, Uuid) {
let host = format!("sec006-{}.example", Uuid::new_v4().simple());
let community = match db
.create_community_with_owner(&host, &hex::encode(creator), &nostr::Keys::generate())
.await
.map(|write| write.outcome)
.expect("create community")
{
buzz_db::CreateCommunityWithOwnerResult::Created(rec) => rec.id,
other => panic!("unexpected community create result: {other:?}"),
};
db.ensure_user(community, creator)
.await
.expect("creator user");
db.ensure_user(community, member)
.await
.expect("member user");
let channel_id = Uuid::new_v4();
db.create_channel_with_id(
community,
channel_id,
&format!("ch-{}", channel_id.simple()),
buzz_db::channel::ChannelType::Stream,
buzz_db::channel::ChannelVisibility::Open,
None,
creator,
None,
)
.await
.expect("create channel");
db.add_member(
community,
channel_id,
member,
buzz_db::channel::MemberRole::Member,
Some(creator),
)
.await
.expect("add member");
(community, channel_id)
}
fn message_event(channel_id: Uuid) -> buzz_core::StoredEvent {
let keys = nostr::Keys::generate();
let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "hello")
.sign_with_keys(&keys)
.expect("sign");
buzz_core::StoredEvent::new(event, Some(channel_id))
}
/// The event path must stop creating runs the moment the workflow's owner
/// loses channel membership — even while the workflow row is still
/// `enabled` (the disable-on-removal side effect is a separate, relay-side
/// write; this gate must hold on its own).
#[tokio::test]
#[ignore = "requires Postgres"]
async fn on_event_denies_run_after_owner_removed() {
let db = setup_db().await;
let creator = nostr::Keys::generate().public_key().to_bytes().to_vec();
let member = nostr::Keys::generate().public_key().to_bytes().to_vec();
let (community, channel_id) = setup_channel(&db, &creator, &member).await;
let def_json = serde_json::json!({
"name": "sec006-event",
"trigger": {"on": "message_posted"},
"steps": [{"id": "s1", "action": "send_message", "text": "hi"}],
"enabled": true,
})
.to_string();
let workflow_id = db
.create_workflow(
community,
Some(channel_id),
&member,
"sec006-event",
&def_json,
&[0u8; 32],
)
.await
.expect("create workflow");
let engine = Arc::new(WorkflowEngine::new(db.clone(), WorkflowConfig::default()));
// Owner is an active member: the event fires the workflow.
engine
.on_event(community, &message_event(channel_id))
.await
.expect("on_event while member");
let runs = db
.list_workflow_runs(community, workflow_id, 10)
.await
.expect("list runs");
assert_eq!(runs.len(), 1, "member owner's workflow must fire");
// Remove the owner (actor = channel creator, an owner-role member).
db.remove_member(community, channel_id, &member, &creator)
.await
.expect("remove member");
// Workflow row is still enabled — only the authority gate stands.
engine
.on_event(community, &message_event(channel_id))
.await
.expect("on_event after removal");
let runs = db
.list_workflow_runs(community, workflow_id, 10)
.await
.expect("list runs after removal");
assert_eq!(
runs.len(),
1,
"no new run may be created after the owner lost membership"
);
}
/// Exfiltration-capable definitions (call_webhook) require the owner to
/// currently hold an elevated role — a plain member's workflow must not
/// fire even though the owner is still an active channel member.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn on_event_denies_webhook_definition_for_plain_member_owner() {
let db = setup_db().await;
let creator = nostr::Keys::generate().public_key().to_bytes().to_vec();
let member = nostr::Keys::generate().public_key().to_bytes().to_vec();
let (community, channel_id) = setup_channel(&db, &creator, &member).await;
let def_json = serde_json::json!({
"name": "sec006-hook",
"trigger": {"on": "message_posted"},
"steps": [{"id": "s1", "action": "call_webhook", "url": "https://example.com/x"}],
"enabled": true,
})
.to_string();
// Same definition, two owners: plain member vs channel owner.
let wf_member = db
.create_workflow(
community,
Some(channel_id),
&member,
"hook-member",
&def_json,
&[0u8; 32],
)
.await
.expect("create member workflow");
let wf_owner = db
.create_workflow(
community,
Some(channel_id),
&creator,
"hook-owner",
&def_json,
&[1u8; 32],
)
.await
.expect("create owner workflow");
let engine = Arc::new(WorkflowEngine::new(db.clone(), WorkflowConfig::default()));
engine
.on_event(community, &message_event(channel_id))
.await
.expect("on_event");
let member_runs = db
.list_workflow_runs(community, wf_member, 10)
.await
.expect("member runs");
assert!(
member_runs.is_empty(),
"plain member's call_webhook workflow must not fire"
);
let owner_runs = db
.list_workflow_runs(community, wf_owner, 10)
.await
.expect("owner runs");
assert_eq!(
owner_runs.len(),
1,
"channel owner's call_webhook workflow fires"
);
}
}