Implement webhook, cron, and Telegram event triggers - #6
Conversation
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>
|
👋 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 New to Jules? Learn more at jules.google/docs. For security, I will only act on instructions from the user who triggered this task. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughThe 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. ChangesTrigger context and workflow summaries
Estimated code review effort: 2 (Simple) | ~10 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (6)
scripts/test-api.js (2)
317-386: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDuplicated cron-matching logic risks silent drift from the real implementation.
localCronMatches/localMatchFieldare copy-pasted fromcronMatches/matchFieldinworker/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/matchFieldinto 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 winTest 14 cannot fail — no real coverage of the scheduled/cron dispatch path.
The
if/elsehere 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,
/__scheduledexposes 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 winSame 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 winOutputs aren't scoped to the node's own trigger type.
This branch assigns
payload/headers/query/cron/event_type/trigger_typefrom the single generictriggerDatato anywebhook_trigger/cron_trigger/telegram_event_triggernode, without checking thatnode.typeactually corresponds totriggerData.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. acron_triggernode showingoutputs.headersfrom 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 winConsider 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 generictrigger_datablob to every trigger-type node regardless of its own type (seescripts/executor.jslines 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 winSame 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
📒 Files selected for processing (5)
scripts/executor.jsscripts/test-api.jsscripts/validate.jsworker/src/index.jsworkflows/workflow.schema.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'); | ||
| 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 | ||
| }; |
There was a problem hiding this comment.
📐 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.
| 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.
| 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' }, | ||
| }); | ||
| } |
There was a problem hiding this comment.
🔒 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.
| 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.
| 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()) | ||
| }); |
There was a problem hiding this comment.
🔒 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.
| 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.
| // 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); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
🩺 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.
| // 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.
| 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; | ||
| } |
There was a problem hiding this comment.
🎯 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.
| 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.
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