Skip to content

Implement webhook, cron, and Telegram event triggers - #6

Merged
quickerup merged 2 commits into
mainfrom
add-trigger-nodes-3986350798834158301
Jul 15, 2026
Merged

Implement webhook, cron, and Telegram event triggers#6
quickerup merged 2 commits into
mainfrom
add-trigger-nodes-3986350798834158301

Conversation

@quickerup

@quickerup quickerup commented Jul 15, 2026

Copy link
Copy Markdown
Owner

This pull request implements the requested trigger mechanisms: Webhook trigger nodes (unauthenticated public POST endpoints supporting optional secrets), Cron schedule trigger nodes (run via Cloudflare Worker scheduled events), and Telegram Event trigger nodes (run via Telegram updates). All trigger payloads, headers, query parameters, or cron data are passed to the executor and made available as outputs for downstream value interpolation. Complete integration tests and validation scripts have been updated and verified successfully.


PR created automatically by Jules for task 3986350798834158301 started by @aethelred-agent-factory

Summary by CodeRabbit

  • New Features
    • Workflow summaries now include details for webhook, scheduled, and Telegram event triggers.
    • Summaries indicate whether webhook triggers are configured with a secret and display relevant schedule or event types.
    • Trigger context is now carried into workflow execution, supporting more consistent handling of triggered runs.

Introduce Webhook (POST /webhooks/:id), Cron (scheduled handler),
and Telegram Event (message, edited_message, etc.) trigger nodes.
Update workflow validation and schema to accept new trigger types.
Pass trigger payloads to the executor for dynamic value interpolation.

Co-authored-by: aethelred-agent-factory <238771426+aethelred-agent-factory@users.noreply.github.com>
@google-labs-jules

Copy link
Copy Markdown
Contributor

👋 Jules, reporting for duty! I'm here to lend a hand with this pull request.

When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down.

I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job!

For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with @jules. You can find this option in the Pull Request section of your global Jules UI settings. You can always switch back!

New to Jules? Learn more at jules.google/docs.


For security, I will only act on instructions from the user who triggered this task.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4e463b65-72fb-4078-bdcf-38e84de8bad9

📥 Commits

Reviewing files that changed from the base of the PR and between 632fd2a and be1daef.

📒 Files selected for processing (3)
  • scripts/executor.js
  • scripts/test-api.js
  • worker/src/index.js

📝 Walkthrough

Walkthrough

The executor now forwards trigger data into node execution, test setup creates temporary worker variables earlier with cleanup support, and workflow summaries include webhook, cron, and Telegram event trigger details.

Changes

Trigger context and workflow summaries

Layer / File(s) Summary
Trigger details in workflow summaries
worker/src/index.js
summarizeWorkflow now includes webhook secret presence and cron or Telegram event type details.
Trigger context execution wiring
scripts/executor.js
Trigger data is read from execution input and forwarded through runNode.
Temporary worker variable setup
scripts/test-api.js
Test setup writes worker/.dev.vars before D1 initialization and adds safe cleanup logic.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

Suggested reviewers: google-labs-jules[bot]

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.26% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding webhook, cron, and Telegram event triggers.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch add-trigger-nodes-3986350798834158301

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (6)
scripts/test-api.js (2)

317-386: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated cron-matching logic risks silent drift from the real implementation.

localCronMatches/localMatchField are copy-pasted from cronMatches/matchField in worker/src/index.js. Assertions are correct today, but this test only validates the copy — if the worker's implementation changes, these unit tests keep passing against stale logic instead of catching regressions.

Consider extracting cronMatches/matchField into a small shared module importable by both the worker and this test script.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/test-api.js` around lines 317 - 386, Extract the shared cron-matching
logic from worker functions cronMatches and matchField into a reusable module,
then import and use it in both worker/src/index.js and scripts/test-api.js.
Remove the duplicated localCronMatches and localMatchField implementations so
the assertions execute the production implementation.

511-524: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test 14 cannot fail — no real coverage of the scheduled/cron dispatch path.

The if/else here never throws: a non-200 response just logs "acceptable" and the suite proceeds either way. This is the only test exercising the scheduled-cron layer described in this PR's objectives, but it doesn't assert that cron matching actually happened or that trigger data was dispatched.

Separately, /__scheduled exposes a /__scheduled fetch route which will trigger a scheduled event (Cron Trigger) for testing during development, but only when wrangler dev is started with --test-scheduled — worth confirming the dev server spawn (not shown in this diff) passes that flag, otherwise this always 404s. Also, a cron query parameter can be passed in to deterministically match a specific cron pattern, and you can also pass a time query parameter to override controller.scheduledTime in your scheduled event listener — using these would let the test deterministically hit the */5 * * * * pattern from the saved workflow and assert real dispatch behavior instead of relying on wall-clock timing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/test-api.js` around lines 511 - 524, Make Test 14 fail when the
scheduled endpoint does not return success, and exercise deterministic cron
matching by supplying the cron and scheduled-time query parameters for the saved
workflow’s */5 * * * * pattern. Confirm the Wrangler dev server is started with
--test-scheduled, then assert observable scheduled-event dispatch or trigger
data rather than merely logging non-200 responses.
scripts/validate.js (1)

98-108: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Same trigger-cardinality gap as the schema.

No check here enforces at most one trigger node (or one trigger type) per workflow. See consolidated comment for full context and affected sites.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/validate.js` around lines 98 - 108, Update the trigger validation
flow in scripts/validate.js to enforce at most one trigger node per workflow,
while preserving the existing per-type configuration checks for webhook_trigger,
cron_trigger, and telegram_event_trigger. Track trigger cardinality during
validation and add an error when a workflow contains multiple trigger nodes.
scripts/executor.js (1)

205-218: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Outputs aren't scoped to the node's own trigger type.

This branch assigns payload/headers/query/cron/event_type/trigger_type from the single generic triggerData to any webhook_trigger/cron_trigger/telegram_event_trigger node, without checking that node.type actually corresponds to triggerData.trigger_type. If a workflow contains multiple different trigger node types (currently unconstrained by schema/validators — see consolidated comment), a node that didn't actually fire would get outputs from an unrelated trigger (e.g. a cron_trigger node showing outputs.headers from a webhook invocation).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/executor.js` around lines 205 - 218, Update the trigger-output
handling in the webhook_trigger/cron_trigger/telegram_event_trigger branch to
assign triggerData only when its trigger_type matches the current node.type. For
mismatched trigger types, leave entry.outputs empty while preserving the
existing success status and matching-trigger output fields.
workflows/workflow.schema.json (1)

132-175: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Consider constraining trigger-node cardinality.

Nothing in this schema prevents a single workflow from mixing different trigger types (webhook_trigger + cron_trigger + telegram_event_trigger) or having several of them. Since the executor applies one generic trigger_data blob to every trigger-type node regardless of its own type (see scripts/executor.js lines 205-218), mixed-trigger workflows can end up with misleading per-node outputs. See consolidated comment below for the full cross-file context.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workflows/workflow.schema.json` around lines 132 - 175, Update the workflow
schema’s trigger-node validation to allow at most one trigger node per workflow
and require all trigger nodes, if present, to use the same trigger type;
preserve the existing webhook_trigger, cron_trigger, and telegram_event_trigger
configuration validation.
worker/src/index.js (1)

769-779: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Same trigger-cardinality gap as the schema/validate.js.

No check here enforces at most one trigger node (or one trigger type) per workflow — see consolidated comment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@worker/src/index.js` around lines 769 - 779, Update the workflow validation
logic around the trigger checks for webhook_trigger, cron_trigger, and
telegram_event_trigger to track trigger nodes and reject workflows containing
more than one trigger, including multiple trigger types. Preserve the existing
per-trigger configuration validation and report the validation error through the
existing errors collection.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/test-api.js`:
- Around line 37-56: Update the test setup around the worker/.dev.vars write and
cleanup function to preserve any pre-existing file before overwriting it, then
restore that backup during cleanup; only remove the generated file when no
original existed, and retain the current cleanup behavior for files created by
the test.

In `@worker/src/index.js`:
- Around line 128-134: Sanitize the webhook metadata before the dispatchWorkflow
call: remove the X-Webhook-Secret entry from the headers object and the secret
query parameter from the query object, while preserving all other incoming
headers and parameters in trigger_data. Update the inline construction in the
webhook handler around dispatchWorkflow; do not pass the raw request headers or
URL search parameters downstream.
- Around line 52-73: Replace the plain secret comparisons in the webhook
validation loop with the file’s existing timingSafeEqual helper, applying it
separately to querySecret and headerSecret while preserving the current
acceptance and unauthorized-response behavior.
- Around line 637-645: Replace the synchronous await of
handleTelegramEventTriggers in the Telegram update path with ctx.waitUntil, so
trigger matching and dispatch run out-of-band without delaying the webhook
response. Preserve the existing env.DB guard and error logging by handling
failures within the deferred promise, following the existing scheduled-handler
pattern.
- Around line 1289-1346: Update matchField so an unparseable no-dash, no-slash
rangePattern returns false immediately instead of falling through to the default
full-range match. Preserve valid single-value, range, list, and step behavior;
do not broaden this change to cron-parser adoption or day-of-month/day-of-week
semantics.
- Around line 40-97: Replace the duplicated node and edge loading/mapping in the
webhook workflow path with the existing hoisted getWorkflowObj(env, id) helper.
Preserve the webhook-node lookup and secret validation, using the helper’s
complete workflow object so positions, inputs, outputs, and other fields remain
consistent.
- Around line 1213-1228: Restrict getEventType to recognized Telegram update and
message content discriminator keys instead of adding every defined field from
update.message. Exclude metadata such as message_id, date, and chat, while
preserving the existing handling of supported top-level update keys and intended
content event types.

---

Nitpick comments:
In `@scripts/executor.js`:
- Around line 205-218: Update the trigger-output handling in the
webhook_trigger/cron_trigger/telegram_event_trigger branch to assign triggerData
only when its trigger_type matches the current node.type. For mismatched trigger
types, leave entry.outputs empty while preserving the existing success status
and matching-trigger output fields.

In `@scripts/test-api.js`:
- Around line 317-386: Extract the shared cron-matching logic from worker
functions cronMatches and matchField into a reusable module, then import and use
it in both worker/src/index.js and scripts/test-api.js. Remove the duplicated
localCronMatches and localMatchField implementations so the assertions execute
the production implementation.
- Around line 511-524: Make Test 14 fail when the scheduled endpoint does not
return success, and exercise deterministic cron matching by supplying the cron
and scheduled-time query parameters for the saved workflow’s */5 * * * *
pattern. Confirm the Wrangler dev server is started with --test-scheduled, then
assert observable scheduled-event dispatch or trigger data rather than merely
logging non-200 responses.

In `@scripts/validate.js`:
- Around line 98-108: Update the trigger validation flow in scripts/validate.js
to enforce at most one trigger node per workflow, while preserving the existing
per-type configuration checks for webhook_trigger, cron_trigger, and
telegram_event_trigger. Track trigger cardinality during validation and add an
error when a workflow contains multiple trigger nodes.

In `@worker/src/index.js`:
- Around line 769-779: Update the workflow validation logic around the trigger
checks for webhook_trigger, cron_trigger, and telegram_event_trigger to track
trigger nodes and reject workflows containing more than one trigger, including
multiple trigger types. Preserve the existing per-trigger configuration
validation and report the validation error through the existing errors
collection.

In `@workflows/workflow.schema.json`:
- Around line 132-175: Update the workflow schema’s trigger-node validation to
allow at most one trigger node per workflow and require all trigger nodes, if
present, to use the same trigger type; preserve the existing webhook_trigger,
cron_trigger, and telegram_event_trigger configuration validation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9bdf1a45-612e-4b59-89ec-ba273addc92e

📥 Commits

Reviewing files that changed from the base of the PR and between 706b8dc and 632fd2a.

📒 Files selected for processing (5)
  • scripts/executor.js
  • scripts/test-api.js
  • scripts/validate.js
  • worker/src/index.js
  • workflows/workflow.schema.json

Comment thread scripts/test-api.js
Comment thread worker/src/index.js
Comment on lines +40 to +97
const { results: nodesRows } = await env.DB.prepare(
`SELECT id, type, config FROM nodes WHERE workflow_id = ?`
).bind(id).all();

const webhookNodes = nodesRows.filter(row => row.type === 'webhook_trigger');
if (webhookNodes.length === 0) {
return new Response(JSON.stringify({ ok: false, error: 'This workflow does not have a webhook trigger' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}

const querySecret = url.searchParams.get('secret');
const headerSecret = request.headers.get('X-Webhook-Secret');

let validated = false;
for (const nodeRow of webhookNodes) {
const config = JSON.parse(nodeRow.config);
if (!config.secret) {
validated = true;
break;
}
if (config.secret === querySecret || config.secret === headerSecret) {
validated = true;
break;
}
}

if (!validated) {
return new Response(JSON.stringify({ ok: false, error: 'Unauthorized: invalid or missing webhook secret' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}

const { results: edgesRows } = await env.DB.prepare(
`SELECT source, target, source_handle FROM edges WHERE workflow_id = ?`
).bind(id).all();

const nodes = nodesRows.map(row => ({
id: row.id,
type: row.type,
position: { x: 0, y: 0 },
config: JSON.parse(row.config)
}));

const edges = edgesRows.map(row => ({
source: row.source,
target: row.target,
sourceHandle: row.source_handle || 'success'
}));

const workflowObj = {
id: workflowRow.id,
name: workflowRow.name,
nodes,
edges
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Reuse getWorkflowObj instead of half-duplicating it here.

This block re-implements workflow/node loading with a reduced field set (SELECT id, type, config only, hardcoded position: {x:0,y:0}, no inputs/outputs), while the getWorkflowObj(env, id) helper added later in this same file (lines 1116-1160) already does this correctly and completely, including position_x/position_y, inputs, and outputs. Because getWorkflowObj is a hoisted function declaration, it can be called from here even though it's defined further down.

♻️ Proposed refactor
-          const workflowRow = await env.DB.prepare(
-            `SELECT id, name FROM workflows WHERE id = ?`
-          ).bind(id).first();
-
-          if (!workflowRow) {
-            return new Response(JSON.stringify({ ok: false, error: 'Workflow not found' }), {
-              status: 404,
-              headers: { 'Content-Type': 'application/json' },
-            });
-          }
-
-          const { results: nodesRows } = await env.DB.prepare(
-            `SELECT id, type, config FROM nodes WHERE workflow_id = ?`
-          ).bind(id).all();
-
-          const webhookNodes = nodesRows.filter(row => row.type === 'webhook_trigger');
+          const workflowObj = await getWorkflowObj(env, id);
+
+          if (!workflowObj) {
+            return new Response(JSON.stringify({ ok: false, error: 'Workflow not found' }), {
+              status: 404,
+              headers: { 'Content-Type': 'application/json' },
+            });
+          }
+
+          const webhookNodes = workflowObj.nodes.filter(n => n.type === 'webhook_trigger');
           if (webhookNodes.length === 0) {
             return new Response(JSON.stringify({ ok: false, error: 'This workflow does not have a webhook trigger' }), {
               status: 400,
               headers: { 'Content-Type': 'application/json' },
             });
           }

           const querySecret = url.searchParams.get('secret');
           const headerSecret = request.headers.get('X-Webhook-Secret');

           let validated = false;
           for (const nodeRow of webhookNodes) {
-            const config = JSON.parse(nodeRow.config);
+            const config = nodeRow.config;
             if (!config.secret) { validated = true; break; }
             // ... (secret check, see next comment about timing-safety)
           }
           // ...
-          const { results: edgesRows } = await env.DB.prepare(
-            `SELECT source, target, source_handle FROM edges WHERE workflow_id = ?`
-          ).bind(id).all();
-
-          const nodes = nodesRows.map(row => ({ id: row.id, type: row.type, position: { x: 0, y: 0 }, config: JSON.parse(row.config) }));
-          const edges = edgesRows.map(row => ({ source: row.source, target: row.target, sourceHandle: row.source_handle || 'success' }));
-          const workflowObj = { id: workflowRow.id, name: workflowRow.name, nodes, edges };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { results: nodesRows } = await env.DB.prepare(
`SELECT id, type, config FROM nodes WHERE workflow_id = ?`
).bind(id).all();
const webhookNodes = nodesRows.filter(row => row.type === 'webhook_trigger');
if (webhookNodes.length === 0) {
return new Response(JSON.stringify({ ok: false, error: 'This workflow does not have a webhook trigger' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const querySecret = url.searchParams.get('secret');
const headerSecret = request.headers.get('X-Webhook-Secret');
let validated = false;
for (const nodeRow of webhookNodes) {
const config = JSON.parse(nodeRow.config);
if (!config.secret) {
validated = true;
break;
}
if (config.secret === querySecret || config.secret === headerSecret) {
validated = true;
break;
}
}
if (!validated) {
return new Response(JSON.stringify({ ok: false, error: 'Unauthorized: invalid or missing webhook secret' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const { results: edgesRows } = await env.DB.prepare(
`SELECT source, target, source_handle FROM edges WHERE workflow_id = ?`
).bind(id).all();
const nodes = nodesRows.map(row => ({
id: row.id,
type: row.type,
position: { x: 0, y: 0 },
config: JSON.parse(row.config)
}));
const edges = edgesRows.map(row => ({
source: row.source,
target: row.target,
sourceHandle: row.source_handle || 'success'
}));
const workflowObj = {
id: workflowRow.id,
name: workflowRow.name,
nodes,
edges
};
const workflowObj = await getWorkflowObj(env, id);
if (!workflowObj) {
return new Response(JSON.stringify({ ok: false, error: 'Workflow not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
const webhookNodes = workflowObj.nodes.filter(n => n.type === 'webhook_trigger');
if (webhookNodes.length === 0) {
return new Response(JSON.stringify({ ok: false, error: 'This workflow does not have a webhook trigger' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const querySecret = url.searchParams.get('secret');
const headerSecret = request.headers.get('X-Webhook-Secret');
let validated = false;
for (const nodeRow of webhookNodes) {
const config = nodeRow.config;
if (!config.secret) {
validated = true;
break;
}
if (config.secret === querySecret || config.secret === headerSecret) {
validated = true;
break;
}
}
if (!validated) {
return new Response(JSON.stringify({ ok: false, error: 'Unauthorized: invalid or missing webhook secret' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@worker/src/index.js` around lines 40 - 97, Replace the duplicated node and
edge loading/mapping in the webhook workflow path with the existing hoisted
getWorkflowObj(env, id) helper. Preserve the webhook-node lookup and secret
validation, using the helper’s complete workflow object so positions, inputs,
outputs, and other fields remain consistent.

Comment thread worker/src/index.js Outdated
Comment on lines +52 to +73
const querySecret = url.searchParams.get('secret');
const headerSecret = request.headers.get('X-Webhook-Secret');

let validated = false;
for (const nodeRow of webhookNodes) {
const config = JSON.parse(nodeRow.config);
if (!config.secret) {
validated = true;
break;
}
if (config.secret === querySecret || config.secret === headerSecret) {
validated = true;
break;
}
}

if (!validated) {
return new Response(JSON.stringify({ ok: false, error: 'Unauthorized: invalid or missing webhook secret' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Use timingSafeEqual for the webhook secret comparison.

config.secret === querySecret || config.secret === headerSecret is a plain string comparison. This same file already avoids this exact pitfal for the Telegram webhook secret via timingSafeEqual (line ~616) specifically to prevent timing attacks. Since /webhooks/:workflowId is explicitly the public, unauthenticated endpoint, this comparison is the more exposed one and should use the same helper.

🔒 Proposed fix
-            if (config.secret === querySecret || config.secret === headerSecret) {
+            if (timingSafeEqual(config.secret, querySecret || '') || timingSafeEqual(config.secret, headerSecret || '')) {
               validated = true;
               break;
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const querySecret = url.searchParams.get('secret');
const headerSecret = request.headers.get('X-Webhook-Secret');
let validated = false;
for (const nodeRow of webhookNodes) {
const config = JSON.parse(nodeRow.config);
if (!config.secret) {
validated = true;
break;
}
if (config.secret === querySecret || config.secret === headerSecret) {
validated = true;
break;
}
}
if (!validated) {
return new Response(JSON.stringify({ ok: false, error: 'Unauthorized: invalid or missing webhook secret' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const querySecret = url.searchParams.get('secret');
const headerSecret = request.headers.get('X-Webhook-Secret');
let validated = false;
for (const nodeRow of webhookNodes) {
const config = JSON.parse(nodeRow.config);
if (!config.secret) {
validated = true;
break;
}
if (timingSafeEqual(config.secret, querySecret || '') || timingSafeEqual(config.secret, headerSecret || '')) {
validated = true;
break;
}
}
if (!validated) {
return new Response(JSON.stringify({ ok: false, error: 'Unauthorized: invalid or missing webhook secret' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@worker/src/index.js` around lines 52 - 73, Replace the plain secret
comparisons in the webhook validation loop with the file’s existing
timingSafeEqual helper, applying it separately to querySecret and headerSecret
while preserving the current acceptance and unauthorized-response behavior.

Comment thread worker/src/index.js Outdated
Comment on lines +128 to +134
const workerUrl = `${url.protocol}//${url.host}`;
await dispatchWorkflow(env, workflowObj, chatId, executionId, workerUrl, {
trigger_type: 'webhook',
payload: postBody,
headers: Object.fromEntries(request.headers.entries()),
query: Object.fromEntries(url.searchParams.entries())
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Webhook secret leaks back into trigger_data.headers/query.

headers: Object.fromEntries(request.headers.entries()) and query: Object.fromEntries(url.searchParams.entries()) capture the raw incoming request verbatim — including the X-Webhook-Secret header and/or ?secret= query param used to authenticate this very request. This payload is dispatched to GitHub Actions (client_payload.trigger_data) and later becomes node outputs in the execution log (scripts/executor.js lines 205-218), so the secret ends up persisted in GitHub Action run history and D1 execution logs, and can be re-exposed downstream if a notify/http node interpolates {{nodes.trigger.outputs.headers...}}.

🛡️ Proposed fix
+          const sanitizedHeaders = Object.fromEntries(request.headers.entries());
+          delete sanitizedHeaders['x-webhook-secret'];
+          const sanitizedQuery = Object.fromEntries(url.searchParams.entries());
+          delete sanitizedQuery.secret;
+
           await dispatchWorkflow(env, workflowObj, chatId, executionId, workerUrl, {
             trigger_type: 'webhook',
             payload: postBody,
-            headers: Object.fromEntries(request.headers.entries()),
-            query: Object.fromEntries(url.searchParams.entries())
+            headers: sanitizedHeaders,
+            query: sanitizedQuery
           });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const workerUrl = `${url.protocol}//${url.host}`;
await dispatchWorkflow(env, workflowObj, chatId, executionId, workerUrl, {
trigger_type: 'webhook',
payload: postBody,
headers: Object.fromEntries(request.headers.entries()),
query: Object.fromEntries(url.searchParams.entries())
});
const workerUrl = `${url.protocol}//${url.host}`;
const sanitizedHeaders = Object.fromEntries(request.headers.entries());
delete sanitizedHeaders['x-webhook-secret'];
const sanitizedQuery = Object.fromEntries(url.searchParams.entries());
delete sanitizedQuery.secret;
await dispatchWorkflow(env, workflowObj, chatId, executionId, workerUrl, {
trigger_type: 'webhook',
payload: postBody,
headers: sanitizedHeaders,
query: sanitizedQuery
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@worker/src/index.js` around lines 128 - 134, Sanitize the webhook metadata
before the dispatchWorkflow call: remove the X-Webhook-Secret entry from the
headers object and the secret query parameter from the query object, while
preserving all other incoming headers and parameters in trigger_data. Update the
inline construction in the webhook handler around dispatchWorkflow; do not pass
the raw request headers or URL search parameters downstream.

Comment thread worker/src/index.js Outdated
Comment on lines +637 to +645
// Telegram Event Triggers: Check if this update matches any telegram_event_trigger workflow
if (env.DB) {
try {
await handleTelegramEventTriggers(env, update, request.url);
} catch (err) {
console.error("Telegram event trigger error:", err);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Don't block the Telegram webhook response on trigger matching.

This awaits handleTelegramEventTriggers synchronously on every single incoming Telegram update (not just confirm/cancel), even for plain /whoami or arbitrary text messages that previously required no DB work at all. Internally it does several sequential D1 round trips per matched workflow (getWorkflowObj alone issues 3 queries) plus an un-timed fetch to the GitHub API in dispatchWorkflow when a match is found — all before the Telegram webhook response is returned. This adds unnecessary latency/timeout risk to the whole bot, unrelated to the message actually being handled. Since ctx is already available in fetch(request, env, ctx), this should run out-of-band via ctx.waitUntil (matching the pattern already used for the scheduled handler at line ~694).

⚡ Proposed fix
-    if (env.DB) {
-      try {
-        await handleTelegramEventTriggers(env, update, request.url);
-      } catch (err) {
-        console.error("Telegram event trigger error:", err);
-      }
-    }
+    if (env.DB) {
+      ctx.waitUntil(
+        handleTelegramEventTriggers(env, update, request.url).catch((err) => {
+          console.error("Telegram event trigger error:", err);
+        })
+      );
+    }

Separately, consider adding a D1 index on nodes(type) (and/or nodes(workflow_id, type)) since this query now runs on every inbound Telegram update.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Telegram Event Triggers: Check if this update matches any telegram_event_trigger workflow
if (env.DB) {
try {
await handleTelegramEventTriggers(env, update, request.url);
} catch (err) {
console.error("Telegram event trigger error:", err);
}
}
// Telegram Event Triggers: Check if this update matches any telegram_event_trigger workflow
if (env.DB) {
ctx.waitUntil(
handleTelegramEventTriggers(env, update, request.url).catch((err) => {
console.error("Telegram event trigger error:", err);
})
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@worker/src/index.js` around lines 637 - 645, Replace the synchronous await of
handleTelegramEventTriggers in the Telegram update path with ctx.waitUntil, so
trigger matching and dispatch run out-of-band without delaying the webhook
response. Preserve the existing env.DB guard and error logging by handling
failures within the deferred promise, following the existing scheduled-handler
pattern.

Comment thread worker/src/index.js Outdated
Comment thread worker/src/index.js Outdated
Comment on lines +1289 to +1346
function cronMatches(cronExpression, date) {
const parts = cronExpression.trim().split(/\s+/);
if (parts.length < 5) return false;

const [minStr, hourStr, domStr, monthStr, dowStr] = parts;

const minutes = date.getUTCMinutes();
const hours = date.getUTCHours();
const dom = date.getUTCDate();
const month = date.getUTCMonth() + 1; // 1-12
const dow = date.getUTCDay(); // 0-6 (Sunday is 0)

return (
matchField(minStr, minutes, 0, 59) &&
matchField(hourStr, hours, 0, 23) &&
matchField(domStr, dom, 1, 31) &&
matchField(monthStr, month, 1, 12) &&
matchField(dowStr, dow, 0, 6)
);
}

function matchField(pattern, val, min, max) {
if (pattern === '*') return true;

if (pattern.includes(',')) {
return pattern.split(',').some(p => matchField(p, val, min, max));
}

let rangePattern = pattern;
let step = 1;
if (pattern.includes('/')) {
const parts = pattern.split('/');
rangePattern = parts[0] === '' || parts[0] === '*' ? `${min}-${max}` : parts[0];
step = parseInt(parts[1], 10);
if (isNaN(step)) return false;
}

let start = min;
let end = max;
if (rangePattern.includes('-')) {
const parts = rangePattern.split('-');
start = parseInt(parts[0], 10);
end = parseInt(parts[1], 10);
if (isNaN(start) || isNaN(end)) return false;
} else {
const single = parseInt(rangePattern, 10);
if (!isNaN(single)) {
if (step === 1) {
return single === val;
}
start = single;
end = max;
}
}

if (val < start || val > end) return false;
return (val - start) % step === 0;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

matchField fails open (matches everything) on any unparseable field value.

In the no-dash, no-slash branch, if rangePattern doesn't parse as an integer (e.g. a typo, or month/day names like "JAN"/"MON", which are common in crontab-style syntax and aren't rejected by the schema), single is NaN and the if (!isNaN(single)) block is skipped entirely — leaving start = min, end = max from the outer defaults. Execution then falls through to return (val - start) % step === 0, which is always true for step defaulting to 1. So an invalid field silently becomes a wildcard instead of never matching — the opposite of a safe failure mode, and it can cause a cron trigger to fire far more often than the user configured (e.g. "0 0 1 JAN *" intended for once a year would fire on the 1st of every month).

🐛 Proposed fix
   } else {
     const single = parseInt(rangePattern, 10);
-    if (!isNaN(single)) {
-      if (step === 1) {
-        return single === val;
-      }
-      start = single;
-      end = max;
-    }
+    if (isNaN(single)) return false;
+    if (step === 1) {
+      return single === val;
+    }
+    start = single;
+    end = max;
   }

Separately (lower priority): standard cron semantics treat day-of-month and day-of-week as OR'd when both are restricted (not *), but this implementation ANDs all five fields unconditionally. Combined with the lack of month/day-name support, consider a well-tested cron parser (e.g. cron-parser) instead of the hand-rolled implementation, given how many edge cases full cron syntax has.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function cronMatches(cronExpression, date) {
const parts = cronExpression.trim().split(/\s+/);
if (parts.length < 5) return false;
const [minStr, hourStr, domStr, monthStr, dowStr] = parts;
const minutes = date.getUTCMinutes();
const hours = date.getUTCHours();
const dom = date.getUTCDate();
const month = date.getUTCMonth() + 1; // 1-12
const dow = date.getUTCDay(); // 0-6 (Sunday is 0)
return (
matchField(minStr, minutes, 0, 59) &&
matchField(hourStr, hours, 0, 23) &&
matchField(domStr, dom, 1, 31) &&
matchField(monthStr, month, 1, 12) &&
matchField(dowStr, dow, 0, 6)
);
}
function matchField(pattern, val, min, max) {
if (pattern === '*') return true;
if (pattern.includes(',')) {
return pattern.split(',').some(p => matchField(p, val, min, max));
}
let rangePattern = pattern;
let step = 1;
if (pattern.includes('/')) {
const parts = pattern.split('/');
rangePattern = parts[0] === '' || parts[0] === '*' ? `${min}-${max}` : parts[0];
step = parseInt(parts[1], 10);
if (isNaN(step)) return false;
}
let start = min;
let end = max;
if (rangePattern.includes('-')) {
const parts = rangePattern.split('-');
start = parseInt(parts[0], 10);
end = parseInt(parts[1], 10);
if (isNaN(start) || isNaN(end)) return false;
} else {
const single = parseInt(rangePattern, 10);
if (!isNaN(single)) {
if (step === 1) {
return single === val;
}
start = single;
end = max;
}
}
if (val < start || val > end) return false;
return (val - start) % step === 0;
}
function cronMatches(cronExpression, date) {
const parts = cronExpression.trim().split(/\s+/);
if (parts.length < 5) return false;
const [minStr, hourStr, domStr, monthStr, dowStr] = parts;
const minutes = date.getUTCMinutes();
const hours = date.getUTCHours();
const dom = date.getUTCDate();
const month = date.getUTCMonth() + 1; // 1-12
const dow = date.getUTCDay(); // 0-6 (Sunday is 0)
return (
matchField(minStr, minutes, 0, 59) &&
matchField(hourStr, hours, 0, 23) &&
matchField(domStr, dom, 1, 31) &&
matchField(monthStr, month, 1, 12) &&
matchField(dowStr, dow, 0, 6)
);
}
function matchField(pattern, val, min, max) {
if (pattern === '*') return true;
if (pattern.includes(',')) {
return pattern.split(',').some(p => matchField(p, val, min, max));
}
let rangePattern = pattern;
let step = 1;
if (pattern.includes('/')) {
const parts = pattern.split('/');
rangePattern = parts[0] === '' || parts[0] === '*' ? `${min}-${max}` : parts[0];
step = parseInt(parts[1], 10);
if (isNaN(step)) return false;
}
let start = min;
let end = max;
if (rangePattern.includes('-')) {
const parts = rangePattern.split('-');
start = parseInt(parts[0], 10);
end = parseInt(parts[1], 10);
if (isNaN(start) || isNaN(end)) return false;
} else {
const single = parseInt(rangePattern, 10);
if (isNaN(single)) return false;
if (step === 1) {
return single === val;
}
start = single;
end = max;
}
if (val < start || val > end) return false;
return (val - start) % step === 0;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@worker/src/index.js` around lines 1289 - 1346, Update matchField so an
unparseable no-dash, no-slash rangePattern returns false immediately instead of
falling through to the default full-range match. Preserve valid single-value,
range, list, and step behavior; do not broaden this change to cron-parser
adoption or day-of-month/day-of-week semantics.

@quickerup
quickerup merged commit 2671491 into main Jul 15, 2026
1 of 2 checks passed
@quickerup
quickerup deleted the add-trigger-nodes-3986350798834158301 branch July 15, 2026 00:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant