fix(workflows): close review blockers — prefetch-safe approvals + loud gate-edge failure

Blocker #1: GET /workflow-approvals/:token/:action no longer mutates. Email
clients + security scanners (Outlook Safe Links, Gmail, Proofpoint, AV
link-checkers) GET links before the human clicks, which previously advanced a
payment-confirm gate silently. GET now renders a confirm/deny interstitial via
a new read-only peekApproval(); only POST calls actByToken.

Blocker #2: a gate decision with no matching edge now failRun()s instead of
finishRun(). resumeRun matches the decision handle EXACTLY (no fall-back to
outEdge's sole-edge heuristic), so a 'deny' with only a 'confirm' edge fails
loudly in run history instead of taking the confirm path / a green 'done'.
This commit is contained in:
Luca
2026-06-23 23:35:45 +02:00
parent d14f1d850c
commit 98ab717043
3 changed files with 92 additions and 32 deletions
+15 -1
View File
@@ -102,6 +102,20 @@ async function actByToken(rawToken, decision) {
return finalizeApproval(approval, decision, { acted_via: 'email' });
}
/**
* Read-only lookup for the emailed token — used to render the confirm/deny
* interstitial WITHOUT mutating state (so email-client prefetchers can't
* advance the gate). Never resumes the run.
*/
async function peekApproval(rawToken) {
const a = await db('workflow_approvals').where({ token_hash: hashToken(rawToken) }).first();
if (!a) return { found: false };
let prompt = null;
try { prompt = (JSON.parse(a.payload || '{}') || {}).prompt || null; } catch (_) { /* ignore */ }
const expired = !!(a.expires_at && new Date(a.expires_at).getTime() < Date.now());
return { found: true, status: a.status, prompt, expired };
}
/** Act on an approval from the admin webview inbox. */
async function actById(id, decision, adminId) {
const approval = await db('workflow_approvals').where({ id }).first();
@@ -123,4 +137,4 @@ async function listPending(limit = 100) {
.limit(limit);
}
module.exports = { hashToken, createApproval, actByToken, actById, listPending };
module.exports = { hashToken, createApproval, actByToken, actById, listPending, peekApproval };
+19 -25
View File
@@ -62,12 +62,14 @@ function matchFilter(filter, payload) {
if (!filter || typeof filter !== 'object') return true;
const { field, op = 'eq', value } = filter;
const actual = payload ? payload[field] : undefined;
// Strict equality: a filter {value: 0} must NOT match false/''/null (loose ==
// conflated them). Numeric payloads vs string config are normalised below.
switch (op) {
case 'neq': return actual != value; // eslint-disable-line eqeqeq
case 'neq': return actual !== value;
case 'truthy': return Boolean(actual);
case 'falsy': return !actual;
case 'eq':
default: return actual == value; // eslint-disable-line eqeqeq
default: return actual === value;
}
}
@@ -228,7 +230,21 @@ async function resumeRun(runId, { decisionHandle = null } = {}) {
const run = await db('workflow_runs').where({ id: runId }).first();
if (!run || run.status !== 'waiting') return;
const { edges } = await loadGraph(run.workflow_id, run.version);
const e = outEdge(edges, run.current_node, decisionHandle);
// For a gate decision, the edge MUST match the handle exactly — we cannot fall
// back to outEdge's "sole edge" heuristic, or a 'deny' with only a 'confirm'
// edge would silently take the confirm path. A missing handle edge is a broken
// graph → fail loudly (same posture as unknown nodes) so the lost decision is
// visible in run history instead of masquerading as a green 'done'.
let e;
if (decisionHandle != null) {
e = edges.find((x) => x.from_node === run.current_node && (x.from_handle || null) === decisionHandle);
if (!e) {
await failRun(runId, `gate decision '${decisionHandle}' has no matching edge from node '${run.current_node}'`);
return;
}
} else {
e = outEdge(edges, run.current_node, null);
}
const nextKey = e ? e.to_node : null;
await db('workflow_runs').where({ id: runId }).update({ status: 'running', current_node: nextKey, wake_at: null, updated_at: db.fn.now() });
if (!nextKey) { await finishRun(runId); return; }
@@ -405,27 +421,6 @@ async function isBuiltinFlowActive(builtinKey) {
}
}
/**
* True when the workflows flag is on AND a built-in flow with this key EXISTS
* (enabled or not). The legacy automations use this to decide whether the engine
* OWNS the automation — once the built-in is seeded, the engine is the single
* switch: the legacy path stands down whether the flow is enabled (the flow
* sends) or disabled (the admin turned it off → nothing sends). Distinct from
* isBuiltinFlowActive, which asks whether the flow is currently firing. Fails
* CLOSED (false) so the legacy path keeps running if the subsystem is down.
*/
async function isBuiltinFlowPresent(builtinKey) {
try {
const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag');
if (!(await isFeatureEnabled('workflows'))) return false;
if (!(await db.schema.hasTable('workflows'))) return false;
const wf = await db('workflows').where({ builtin_key: builtinKey }).first();
return !!wf;
} catch (e) {
return false;
}
}
/**
* Emit `event.date_approaching` for events entering an enabled flow's lead
* window. This is the trigger source for the pre-event reminder built-in, so it
@@ -550,7 +545,6 @@ async function testRun(workflowId, { entityType = null, entityId = null, payload
module.exports = {
emitWorkflowEvent,
isBuiltinFlowActive,
isBuiltinFlowPresent,
runDueWaits,
emitDueEventReminders,
recoverStaleRuns,