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
+58 -6
View File
@@ -1,16 +1,19 @@
/**
* Public workflow-approval endpoint — the confirm/deny links emailed to the
* admin when a workflow gate is reached. Token is the single-use raw value
* (hashed at rest); the action resumes the run down the matching edge.
* (hashed at rest); acting resumes the run down the matching edge.
*
* GET is used so the link is clickable from an email client. The token is
* single-use and the handler is idempotent (a second click shows "already
* recorded"), so prefetching can't double-act.
* Prefetch safety: GET is NEVER state-changing. Email clients + security
* scanners (Outlook Safe Links, Gmail, Proofpoint, AV link-checkers) GET email
* URLs before the human clicks — a GET that acted would silently advance a
* payment-confirm gate. So GET renders an interstitial with buttons that POST
* the decision; only POST calls actByToken. The token still gates everything
* (256-bit, single-use), and prefetchers don't POST.
*/
const express = require('express');
const router = express.Router();
const { actByToken } = require('../services/workflows');
const { actByToken, peekApproval } = require('../services/workflows');
function page(title, body) {
return `<!doctype html><html><head><meta charset="utf-8">`
@@ -20,7 +23,56 @@ function page(title, body) {
+ `<h2 style="font-weight:600">${title}</h2><p style="color:#4b5563;line-height:1.6">${body}</p></body></html>`;
}
// Escape any prompt text we echo into the interstitial HTML.
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
function decisionPage(token, emphasis, prompt) {
const btn = (href, label, primary) => `<form method="POST" action="${href}" style="display:inline">`
+ `<button type="submit" style="cursor:pointer;margin:6px;padding:12px 20px;border-radius:8px;border:1px solid #d1d5db;`
+ `font-size:15px;font-weight:600;${primary
? 'background:#1d9e75;color:#fff;border-color:#1d9e75'
: 'background:#fff;color:#374151'}">${label}</button></form>`;
const body = (prompt ? `<span style="display:block;margin-bottom:16px">${esc(prompt)}</span>` : '')
+ `<div>`
+ btn(`confirm`, 'Confirm payment received', emphasis === 'confirm')
+ btn(`deny`, 'No payment received', emphasis === 'deny')
+ `</div>`
+ `<p style="color:#9ca3af;font-size:13px;margin-top:20px">Choosing is a single, final action.</p>`;
return page('Confirm your response', body);
}
// GET — render the interstitial. READ-ONLY: never mutates / resumes.
router.get('/:token/:action', async (req, res) => {
const { token, action } = req.params;
if (!['confirm', 'deny'].includes(action)) {
return res.status(400).send(page('Invalid link', 'This confirmation link is not valid.'));
}
try {
const info = await peekApproval(token);
if (!info.found) {
return res.status(404).send(page('Link not found', 'This confirmation link is invalid or has been revoked.'));
}
if (info.status !== 'pending') {
return res.send(page('Already recorded', `This request was already ${esc(info.status)}.`));
}
if (info.expired) {
return res.status(410).send(page('Link expired', 'This confirmation link has expired. Use the workflow inbox in the admin panel instead.'));
}
// Relative form actions resolve against the current path's directory; the
// emphasis just highlights the button matching the link they clicked.
return res.send(decisionPage(token, action, info.prompt));
} catch (e) {
return res.status(500).send(page('Something went wrong', 'Please try again or use the admin panel.'));
}
});
// POST — the actual decision. Only a human (or an explicit form submit) reaches
// here; prefetchers issue GET, not POST.
router.post('/:token/:action', async (req, res) => {
const { token, action } = req.params;
if (!['confirm', 'deny'].includes(action)) {
return res.status(400).send(page('Invalid link', 'This confirmation link is not valid.'));
@@ -34,7 +86,7 @@ router.get('/:token/:action', async (req, res) => {
return res.status(410).send(page('Link expired', 'This confirmation link has expired. Use the workflow inbox in the admin panel instead.'));
}
if (result.already) {
return res.send(page('Already recorded', `This request was already ${result.status}.`));
return res.send(page('Already recorded', `This request was already ${esc(result.status)}.`));
}
return res.send(page(
'Thank you',