Explorar el Código

docs: implementation plan for form triggers

fszontagh hace 1 mes
padre
commit
214d106c92
Se han modificado 1 ficheros con 1443 adiciones y 0 borrados
  1. 1443 0
      docs/superpowers/plans/2026-08-09-form-trigger.md

+ 1443 - 0
docs/superpowers/plans/2026-08-09-form-trigger.md

@@ -0,0 +1,1443 @@
+# Form Trigger Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A workflow can publish an HTML form at a shareable URL; submitting it - including a file upload - starts the workflow with the answers as trigger data.
+
+**Architecture:** A new `form-trigger` node carries the form definition. `WebhookController` gains a form branch that runs *before* its HTTP-method-to-node-type mapping, because one node must answer both GET (render the page) and POST (accept the submission). Uploaded files are converted to the same `{type:'binary', ...}` object `http-request` already produces, so existing image nodes accept them unchanged. Separately, the ten self-contained nodes of the anime pipeline are extracted into a sub-workflow that both the RSS loop and a new upload form call.
+
+**Tech Stack:** C++20, cpp-httplib 0.18.3 (`req.files`), OpenSSL HMAC (already used by `JwtUtils`), nlohmann/json, QuickJS nodes in `nodes/`, React/TypeScript WebUI.
+
+## Global Constraints
+
+- Spec: `docs/superpowers/specs/2026-08-09-form-trigger-design.md`. Read it before Task 1.
+- C++20 strict, warnings `-Wall -Wextra -Wpedantic` must stay clean.
+- **Never use em-dashes or en-dashes** in any output: code, comments, commits, log strings, HTML copy. ASCII hyphens only.
+- Node tests are JSON cases in `tests/nodes/*.json`, run by `./scripts/run-node-tests.sh` against **live services**. The full suite must be green before any commit. It was 70 passed / 0 failed at the start of this work.
+- Rebuild with `cmake --build build -j$(nproc)`; restart with `systemctl --user restart smartbotic-webserver` (and `smartbotic-runner` when node files change).
+- After editing anything in `nodes/`, migrate it: see "Migrating Nodes to Database" in `CLAUDE.md`.
+- Uploaded-file objects must match `http-request.js:457` exactly: `{type, data, mimeType, filename, size, checksum}` where `data` is base64 and `checksum` is `sha256(base64Data)`.
+- Field `name` values are restricted to `^[A-Za-z_][A-Za-z0-9_]*$` and must be unique within one form.
+
+---
+
+### Task 1: The `form-trigger` node definition
+
+Declares the form. No serving yet - this task is the schema and the trigger-data mapping, so the WebUI can render an editor and the controller has something to read.
+
+**Files:**
+- Create: `nodes/triggers/form-trigger.js`
+- Test: `tests/nodes/form-trigger-shape.json`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: node type `form-trigger` whose `config` is `{title, description, fields[], password, responseMode, responseMessage, path}`. Each entry of `fields[]` is `{name, label, type, required, placeholder, options, accept, maxSizeMb}`. `execute()` returns `{form, submittedAt, clientIp}`. Task 3 reads this config; Task 8 reads the returned shape.
+
+- [ ] **Step 1: Write the failing test**
+
+`tests/nodes/form-trigger-shape.json` - the node must pass the submitted form through untouched when the controller supplies trigger data. Executing it directly (no webhook) means `context.triggerData` is empty, which must yield an empty form rather than an error:
+
+```json
+{
+  "name": "verify-form-trigger-shape",
+  "nodes": [
+    {"id": "n1", "name": "Form", "type": "form-trigger", "position": {"x": 0, "y": 0},
+     "config": {"title": "Send an image",
+                "fields": [{"name": "image", "label": "Image", "type": "file", "required": true}]}},
+    {"id": "n2", "name": "Check", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { hasForm: typeof data.form === 'object' && data.form !== null, submitted: typeof data.submittedAt === 'number' };"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"}
+  ],
+  "expect": {
+    "n2": {"status": "completed", "output": {"result": {"hasForm": true, "submitted": true}}}
+  }
+}
+```
+
+- [ ] **Step 2: Run it and watch it fail**
+
+```bash
+python3 scripts/verify-node.py tests/nodes/form-trigger-shape.json
+```
+Expected: FAIL - the node type `form-trigger` does not exist.
+
+- [ ] **Step 3: Write the node**
+
+`nodes/triggers/form-trigger.js`. Follow the header-comment convention in `nodes/triggers/post-trigger.js` exactly - the `@node`, `@name`, `@category`, `@trigger` tags are how it is discovered.
+
+```javascript
+/**
+ * @node form-trigger
+ * @name Form
+ * @category triggers
+ * @version 1.0.0
+ * @description Publishes an HTML form at a shareable URL. Submitting it starts the workflow.
+ * @trigger
+ * @icon clipboard-list
+ */
+
+const configSchema = {
+  type: 'object',
+  properties: {
+    title: {
+      type: 'string',
+      title: 'Form Title',
+      description: 'Shown as the heading of the page',
+      default: 'Untitled form'
+    },
+    description: {
+      type: 'string',
+      title: 'Description',
+      description: 'Shown under the heading. Plain text - it is escaped, not rendered as HTML.',
+      default: ''
+    },
+    fields: {
+      type: 'array',
+      title: 'Fields',
+      description: 'The questions on the form, in order.',
+      default: [],
+      items: {
+        type: 'object',
+        properties: {
+          name: { type: 'string', title: 'Name',
+                  description: 'The key the workflow reads, e.g. "image" for $node[\'Form\'].form.image. Letters, digits and underscore, not starting with a digit.' },
+          label: { type: 'string', title: 'Label', description: 'Shown next to the input' },
+          type: { type: 'string', title: 'Type',
+                  enum: ['text', 'textarea', 'number', 'select', 'checkbox', 'date', 'file'],
+                  default: 'text' },
+          required: { type: 'boolean', title: 'Required', default: false },
+          placeholder: { type: 'string', title: 'Placeholder', default: '' },
+          options: { type: 'array', title: 'Options', description: 'Choices, for a Select field',
+                     items: { type: 'string' }, default: [] },
+          accept: { type: 'string', title: 'Accept',
+                    description: 'For a File field, e.g. "image/*". A browser hint only - it is not enforced.',
+                    default: '' },
+          maxSizeMb: { type: 'number', title: 'Max Size (MB)',
+                       description: 'For a File field. 0 uses the server limit.', default: 0 }
+        }
+      }
+    },
+    password: {
+      type: 'string',
+      title: 'Password',
+      format: 'password',
+      description: 'Leave empty for anyone with the link. This is stored readable in the workflow, so treat it as a gate against a forwarded link, not as a secret.',
+      default: ''
+    },
+    responseMode: {
+      type: 'string',
+      title: 'After Submit',
+      enum: ['immediate', 'wait'],
+      description: 'Immediate returns a thank-you page at once and lets the run continue - needed for anything taking more than about 30 seconds. Wait holds the browser until the workflow finishes, so a Respond to Webhook node can answer.',
+      default: 'immediate'
+    },
+    responseMessage: {
+      type: 'string',
+      title: 'Thank-you Message',
+      default: 'Thanks, your answer was received.',
+      'x-if': 'responseMode'
+    },
+    path: {
+      type: 'string',
+      title: 'Path Filter',
+      description: 'Optional path suffix. Leave empty to answer at the bare form URL.',
+      default: ''
+    }
+  }
+};
+
+const inputSchema = { type: 'object', properties: {} };
+
+const outputSchema = {
+  type: 'object',
+  properties: {
+    form: { type: 'object', description: 'The answers, keyed by field name. A file field holds a binary object.' },
+    submittedAt: { type: 'number', description: 'Milliseconds since the epoch' },
+    clientIp: { type: 'string' }
+  }
+};
+
+async function execute(config, input, context) {
+  // Every value here is built by the webhook controller, which has already
+  // validated it. Executed directly - from the editor's Run button - there is
+  // no submission, and an empty form is the honest answer rather than an error.
+  const triggerData = context.triggerData || {};
+
+  return {
+    form: triggerData.form || {},
+    submittedAt: triggerData.submittedAt || Date.now(),
+    clientIp: triggerData.clientIp || ''
+  };
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };
+```
+
+- [ ] **Step 4: Migrate the node and re-run the test**
+
+```bash
+TOKEN=$(curl -s http://localhost:8090/api/v1/auth/login -H "Content-Type: application/json" \
+  -d '{"username": "admin", "password": "admin"}' | jq -r '.accessToken')
+curl -X POST http://localhost:8090/api/v1/nodes/migrate -H "Authorization: Bearer $TOKEN" \
+  -H "Content-Type: application/json" -d '{"nodesPath": "./nodes"}'
+python3 scripts/verify-node.py tests/nodes/form-trigger-shape.json
+```
+Expected: PASS.
+
+- [ ] **Step 5: Run the whole suite and commit**
+
+```bash
+./scripts/run-node-tests.sh
+git add nodes/triggers/form-trigger.js tests/nodes/form-trigger-shape.json
+git commit -m "feat: a form-trigger node describing a form and its answers"
+```
+
+---
+
+### Task 2: Cap the request body
+
+Do this before anything serves an upload. `CPPHTTPLIB_PAYLOAD_MAX_LENGTH` is `SIZE_MAX` and bodies buffer whole in memory, so every public webhook is currently a way to exhaust the webserver. This is a standalone fix and is worth its own commit.
+
+**Files:**
+- Modify: `src/webserver/webserver_service.cpp` (config load near line 195, and where the `httplib::Server` is configured)
+- Modify: `config/webserver.json`
+- Test: `tests/nodes/webhook-body-too-large.json`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: config key `server.max_upload_mb` (integer, default 32), applied via `server.set_payload_max_length()`. Task 3 reads the same value to report a per-field limit.
+
+- [ ] **Step 1: Write the failing test**
+
+`tests/nodes/webhook-body-too-large.json`. The harness sends a JSON body, so a large string is enough to cross the cap:
+
+```json
+{
+  "name": "verify-webhook-body-too-large",
+  "requires": {"note": "expects server.max_upload_mb to be set to 1 for this case"},
+  "nodes": [
+    {"id": "n1", "name": "Webhook", "type": "post-trigger", "position": {"x": 0, "y": 0}, "config": {}}
+  ],
+  "connections": [],
+  "http": {
+    "method": "POST",
+    "path": "/webhook/{workflowId}",
+    "bodyPadBytes": 2097152,
+    "expectStatus": 413
+  }
+}
+```
+
+`bodyPadBytes` does not exist yet. Add it to `run_http_case` in `scripts/verify-node.py`, right after the `data = json.dumps(...)` line:
+
+```python
+    body = spec.get("body", {})
+    pad = spec.get("bodyPadBytes", 0)
+    if pad:
+        body = dict(body)
+        body["_pad"] = "x" * pad
+    data = json.dumps(body).encode()
+```
+
+- [ ] **Step 2: Run it and watch it fail**
+
+```bash
+python3 scripts/verify-node.py tests/nodes/webhook-body-too-large.json
+```
+Expected: FAIL - the oversized body is accepted, giving 200 or 500 rather than 413.
+
+- [ ] **Step 3: Read the limit from config**
+
+In `src/webserver/webserver_service.cpp`, beside the other `cfg.getOr` calls around line 195:
+
+```cpp
+        // A public webhook takes a body into memory whole, and httplib's
+        // default ceiling is SIZE_MAX - so without this any URL under /webhook
+        // is a way to exhaust the process from off the network.
+        config.max_upload_mb = cfg.getOr<int>("server.max_upload_mb", 32);
+```
+
+Add `int max_upload_mb = 32;` to the config struct that surrounds it, and where the `httplib::Server` is constructed:
+
+```cpp
+    server.set_payload_max_length(static_cast<size_t>(config.max_upload_mb) * 1024 * 1024);
+```
+
+Add to `config/webserver.json` under `server`:
+
+```json
+    "max_upload_mb": 32
+```
+
+- [ ] **Step 4: Rebuild, restart, and verify both directions**
+
+```bash
+cmake --build build -j$(nproc)
+```
+Temporarily set `max_upload_mb` to 1 in `config/webserver.json`, restart, then:
+```bash
+systemctl --user restart smartbotic-webserver && sleep 5
+python3 scripts/verify-node.py tests/nodes/webhook-body-too-large.json
+```
+Expected: PASS (413).
+
+Now check the limit does not reject what it should accept - a cap that rejects everything would pass the test above while breaking every webhook:
+```bash
+python3 scripts/verify-node.py tests/nodes/respond-to-webhook.json
+```
+Expected: PASS. Then restore `max_upload_mb` to 32 and restart.
+
+- [ ] **Step 5: Run the whole suite and commit**
+
+```bash
+./scripts/run-node-tests.sh
+git add src/webserver/webserver_service.cpp config/webserver.json scripts/verify-node.py tests/nodes/webhook-body-too-large.json
+git commit -m "fix: bound the request body a webhook will buffer"
+```
+
+---
+
+### Task 3: Render the form on GET
+
+**Files:**
+- Create: `src/webserver/api/form_renderer.hpp`, `src/webserver/api/form_renderer.cpp`
+- Modify: `CMakeLists.txt` (add `form_renderer.cpp` to the webserver sources)
+- Modify: `src/webserver/api/webhook_controller.cpp` (`handleWebhook`, `findTriggerNode`), `src/webserver/api/webhook_controller.hpp`
+- Test: `tests/nodes/form-render.json`, `tests/nodes/form-render-escaping.json`
+
+Rendering lives in its own file rather than growing `webhook_controller.cpp` (486 lines already): HTML generation and escaping are one responsibility, and they are the part worth reading closely.
+
+**Interfaces:**
+- Consumes: the `form-trigger` config from Task 1; `max_upload_mb` from Task 2.
+- Produces:
+  ```cpp
+  namespace smartbotic::webserver::api::form_renderer {
+  std::string escapeHtml(const std::string& raw);
+  std::string renderForm(const nlohmann::json& config, const std::string& action_url,
+                         const std::string& error_message);
+  std::string renderPasswordPrompt(const std::string& action_url, const std::string& error_message);
+  std::string renderMessage(const std::string& title, const std::string& message);
+  }
+  ```
+  Task 4 calls `renderPasswordPrompt`; Task 5 calls `renderMessage`.
+
+- [ ] **Step 1: Write the failing tests**
+
+`tests/nodes/form-render.json` - a GET returns an HTML page carrying the fields:
+
+```json
+{
+  "name": "verify-form-render",
+  "nodes": [
+    {"id": "n1", "name": "Form", "type": "form-trigger", "position": {"x": 0, "y": 0},
+     "config": {"title": "Send an image", "description": "Pick a picture",
+                "fields": [
+                  {"name": "caption", "label": "Caption", "type": "text", "required": true},
+                  {"name": "image", "label": "Image", "type": "file", "required": true, "accept": "image/*"}
+                ]}}
+  ],
+  "connections": [],
+  "http": {
+    "method": "GET",
+    "path": "/webhook/{workflowId}",
+    "expectStatus": 200,
+    "expectHeaders": {"Content-Type": "text/html"},
+    "expectBodyContains": [
+      "Send an image", "Pick a picture",
+      "name=\"caption\"", "name=\"image\"",
+      "type=\"file\"", "accept=\"image/*\"",
+      "enctype=\"multipart/form-data\""
+    ]
+  }
+}
+```
+
+`tests/nodes/form-render-escaping.json` - a form definition is user-authored and the page is public, so a title containing markup must arrive as text:
+
+```json
+{
+  "name": "verify-form-render-escaping",
+  "nodes": [
+    {"id": "n1", "name": "Form", "type": "form-trigger", "position": {"x": 0, "y": 0},
+     "config": {"title": "<script>alert(1)</script>",
+                "description": "a \"quoted\" & <b>bold</b> description",
+                "fields": [{"name": "q", "label": "<img src=x onerror=alert(2)>", "type": "text"}]}}
+  ],
+  "connections": [],
+  "http": {
+    "method": "GET",
+    "path": "/webhook/{workflowId}",
+    "expectStatus": 200,
+    "expectBodyContains": [
+      "&lt;script&gt;alert(1)&lt;/script&gt;",
+      "&lt;img src=x onerror=alert(2)&gt;",
+      "&amp;"
+    ],
+    "expectBodyExcludes": ["<script>alert(1)</script>", "<img src=x onerror"]
+  }
+}
+```
+
+`expectBodyExcludes` does not exist yet. Add it to `run_http_case` in `scripts/verify-node.py`, after the `expectBodyContains` loop:
+
+```python
+    for fragment in spec.get("expectBodyExcludes", []):
+        if fragment in raw:
+            failures.append("body: expected NOT to contain %r" % fragment)
+```
+
+The harness also always sends a JSON body and a Content-Type. Make it skip both when there is no body, so a GET is a real GET:
+
+```python
+    method = spec.get("method", "POST")
+    data = None
+    if method in ("POST", "PUT", "PATCH"):
+        ...existing body construction...
+    req = urllib.request.Request(url, data=data, method=method)
+    if data is not None:
+        req.add_header("Content-Type", "application/json")
+```
+
+- [ ] **Step 2: Run them and watch them fail**
+
+```bash
+python3 scripts/verify-node.py tests/nodes/form-render.json
+python3 scripts/verify-node.py tests/nodes/form-render-escaping.json
+```
+Expected: both FAIL - a GET currently finds no `get-trigger` node and runs the workflow instead of rendering.
+
+- [ ] **Step 3: Write the renderer**
+
+`src/webserver/api/form_renderer.hpp`:
+
+```cpp
+#pragma once
+
+#include <nlohmann/json.hpp>
+#include <string>
+
+namespace smartbotic::webserver::api::form_renderer {
+
+/// Every value interpolated into a form page passes through here.
+///
+/// The form definition is written by a SmartBotic user and the page is served
+/// to anybody with the link, so an unescaped label is stored XSS on a public
+/// URL. This is the boundary; there is no other.
+std::string escapeHtml(const std::string& raw);
+
+/// The form itself. `error_message` is shown above the fields when a submission
+/// came back invalid, and is empty on a first view.
+std::string renderForm(const nlohmann::json& config, const std::string& action_url,
+                       const std::string& error_message);
+
+/// Shown instead of the form when the form has a password and the request has
+/// no valid cookie.
+std::string renderPasswordPrompt(const std::string& action_url, const std::string& error_message);
+
+/// A plain page - the thank-you after a submission, or a refusal.
+std::string renderMessage(const std::string& title, const std::string& message);
+
+}  // namespace smartbotic::webserver::api::form_renderer
+```
+
+`src/webserver/api/form_renderer.cpp`:
+
+```cpp
+#include "form_renderer.hpp"
+
+#include <sstream>
+
+namespace smartbotic::webserver::api::form_renderer {
+
+namespace {
+
+// Self-contained on purpose: no CDN stylesheet, no webfont, no script. A form
+// has to work on a network that cannot reach the internet, and its behaviour
+// should be a property of this repository rather than of somebody else's host.
+constexpr const char* kStyle = R"(
+body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; background: #f6f7f9;
+       margin: 0; padding: 2rem 1rem; color: #1f2328; }
+.card { max-width: 34rem; margin: 0 auto; background: #fff; border-radius: 10px;
+        padding: 1.75rem; box-shadow: 0 1px 3px rgba(0,0,0,.12); }
+h1 { font-size: 1.35rem; margin: 0 0 .35rem; }
+p.desc { color: #57606a; margin: 0 0 1.5rem; }
+label { display: block; font-weight: 600; font-size: .9rem; margin: 1rem 0 .35rem; }
+.req { color: #b3261e; font-weight: 400; }
+input[type=text], input[type=number], input[type=date], input[type=password], textarea, select {
+  width: 100%; box-sizing: border-box; padding: .55rem .65rem; font-size: 1rem;
+  border: 1px solid #d0d7de; border-radius: 6px; background: #fff; }
+textarea { min-height: 6rem; resize: vertical; }
+input[type=file] { width: 100%; }
+button { margin-top: 1.5rem; width: 100%; padding: .7rem; font-size: 1rem; font-weight: 600;
+         color: #fff; background: #1f6feb; border: 0; border-radius: 6px; cursor: pointer; }
+button:hover { background: #1a5fd0; }
+.err { background: #fff1f0; border: 1px solid #ffccc7; color: #a8071a;
+       padding: .65rem .8rem; border-radius: 6px; margin-bottom: 1rem; font-size: .9rem; }
+)";
+
+std::string page(const std::string& title, const std::string& body) {
+    std::ostringstream out;
+    out << "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">"
+        << "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
+        << "<title>" << escapeHtml(title) << "</title><style>" << kStyle << "</style></head>"
+        << "<body><div class=\"card\">" << body << "</div></body></html>";
+    return out.str();
+}
+
+std::string errorBlock(const std::string& message) {
+    if (message.empty()) return "";
+    return "<div class=\"err\">" + escapeHtml(message) + "</div>";
+}
+
+}  // namespace
+
+std::string escapeHtml(const std::string& raw) {
+    std::string out;
+    out.reserve(raw.size());
+    for (char c : raw) {
+        switch (c) {
+            case '&':  out += "&amp;";  break;
+            case '<':  out += "&lt;";   break;
+            case '>':  out += "&gt;";   break;
+            case '"':  out += "&quot;"; break;
+            case '\'': out += "&#39;";  break;
+            default:   out += c;        break;
+        }
+    }
+    return out;
+}
+
+std::string renderForm(const nlohmann::json& config, const std::string& action_url,
+                       const std::string& error_message) {
+    const auto fields = config.value("fields", nlohmann::json::array());
+
+    bool has_file = false;
+    for (const auto& f : fields) {
+        if (f.value("type", "text") == "file") { has_file = true; break; }
+    }
+
+    std::ostringstream body;
+    body << "<h1>" << escapeHtml(config.value("title", "Untitled form")) << "</h1>";
+    const std::string desc = config.value("description", "");
+    if (!desc.empty()) body << "<p class=\"desc\">" << escapeHtml(desc) << "</p>";
+    body << errorBlock(error_message);
+
+    body << "<form method=\"POST\" action=\"" << escapeHtml(action_url) << "\"";
+    // Without this a browser sends urlencoded and the file arrives as a bare
+    // filename, which looks like a working form that silently loses the upload.
+    if (has_file) body << " enctype=\"multipart/form-data\"";
+    body << ">";
+
+    for (const auto& f : fields) {
+        const std::string name = f.value("name", "");
+        if (name.empty()) continue;
+        const std::string type = f.value("type", "text");
+        const std::string label = f.value("label", name);
+        const bool required = f.value("required", false);
+        const std::string placeholder = escapeHtml(f.value("placeholder", ""));
+        const std::string req_attr = required ? " required" : "";
+
+        body << "<label for=\"" << escapeHtml(name) << "\">" << escapeHtml(label);
+        if (required) body << " <span class=\"req\">*</span>";
+        body << "</label>";
+
+        const std::string common = "id=\"" + escapeHtml(name) + "\" name=\"" + escapeHtml(name) + "\"";
+
+        if (type == "textarea") {
+            body << "<textarea " << common << " placeholder=\"" << placeholder << "\""
+                 << req_attr << "></textarea>";
+        } else if (type == "select") {
+            body << "<select " << common << req_attr << ">";
+            if (!required) body << "<option value=\"\"></option>";
+            for (const auto& opt : f.value("options", nlohmann::json::array())) {
+                const std::string v = escapeHtml(opt.is_string() ? opt.get<std::string>() : opt.dump());
+                body << "<option value=\"" << v << "\">" << v << "</option>";
+            }
+            body << "</select>";
+        } else if (type == "checkbox") {
+            body << "<input type=\"checkbox\" " << common << " value=\"true\"" << req_attr << ">";
+        } else if (type == "file") {
+            body << "<input type=\"file\" " << common;
+            const std::string accept = f.value("accept", "");
+            if (!accept.empty()) body << " accept=\"" << escapeHtml(accept) << "\"";
+            body << req_attr << ">";
+        } else if (type == "number") {
+            body << "<input type=\"number\" " << common << " placeholder=\"" << placeholder
+                 << "\"" << req_attr << ">";
+        } else if (type == "date") {
+            body << "<input type=\"date\" " << common << req_attr << ">";
+        } else {
+            body << "<input type=\"text\" " << common << " placeholder=\"" << placeholder
+                 << "\"" << req_attr << ">";
+        }
+    }
+
+    body << "<button type=\"submit\">Submit</button></form>";
+    return page(config.value("title", "Form"), body.str());
+}
+
+std::string renderPasswordPrompt(const std::string& action_url, const std::string& error_message) {
+    std::ostringstream body;
+    body << "<h1>This form is protected</h1>"
+         << "<p class=\"desc\">Enter the password you were given.</p>"
+         << errorBlock(error_message)
+         << "<form method=\"POST\" action=\"" << escapeHtml(action_url) << "\">"
+         << "<label for=\"__form_password\">Password</label>"
+         << "<input type=\"password\" id=\"__form_password\" name=\"__form_password\" required>"
+         << "<button type=\"submit\">Continue</button></form>";
+    return page("Protected form", body.str());
+}
+
+std::string renderMessage(const std::string& title, const std::string& message) {
+    std::ostringstream body;
+    body << "<h1>" << escapeHtml(title) << "</h1>"
+         << "<p class=\"desc\">" << escapeHtml(message) << "</p>";
+    return page(title, body.str());
+}
+
+}  // namespace smartbotic::webserver::api::form_renderer
+```
+
+Add `src/webserver/api/form_renderer.cpp` to the webserver target's source list in `CMakeLists.txt`, beside the other `src/webserver/api/*.cpp` entries.
+
+- [ ] **Step 4: Branch on the form node in the controller**
+
+In `webhook_controller.hpp`, declare beside the existing helpers:
+
+```cpp
+    std::optional<nlohmann::json> findFormNode(const nlohmann::json& workflow);
+    void handleFormGet(const httplib::Request& req, httplib::Response& res,
+                       const nlohmann::json& node, const std::string& workflow_id,
+                       const std::string& path);
+```
+
+In `webhook_controller.cpp`, inside `handleWebhook`, immediately after the `active` check and **before** `findTriggerNode`:
+
+```cpp
+    // A form is one node answering two verbs - GET renders it, POST submits it -
+    // which the method-to-node-type mapping below cannot express, so it is
+    // matched first.
+    auto form_node = findFormNode(workflow);
+    if (form_node.has_value()) {
+        const auto form_config = form_node->value("config", nlohmann::json::object());
+        const std::string config_path = form_config.value("path", "");
+        if (!config_path.empty() && path != config_path) {
+            sendError(res, "Path not found", 404);
+            return;
+        }
+        if (req.method == "GET") {
+            handleFormGet(req, res, form_node.value(), workflow_id, path);
+            return;
+        }
+    }
+```
+
+And the two new methods:
+
+```cpp
+std::optional<nlohmann::json> WebhookController::findFormNode(const nlohmann::json& workflow) {
+    for (const auto& node : workflow.value("nodes", nlohmann::json::array())) {
+        if (node.value("type", "") == "form-trigger" && !node.value("disabled", false)) {
+            return node;
+        }
+    }
+    return std::nullopt;
+}
+
+void WebhookController::handleFormGet(const httplib::Request& req, httplib::Response& res,
+                                      const nlohmann::json& node, const std::string& workflow_id,
+                                      const std::string& path) {
+    (void)req;
+    const auto config = node.value("config", nlohmann::json::object());
+    const std::string action = "/webhook/" + workflow_id + path;
+    res.status = 200;
+    res.set_content(form_renderer::renderForm(config, action, ""), "text/html; charset=utf-8");
+}
+```
+
+Add `#include "form_renderer.hpp"` at the top.
+
+- [ ] **Step 5: Rebuild, restart, verify**
+
+```bash
+cmake --build build -j$(nproc) && systemctl --user restart smartbotic-webserver && sleep 5
+python3 scripts/verify-node.py tests/nodes/form-render.json
+python3 scripts/verify-node.py tests/nodes/form-render-escaping.json
+```
+Expected: both PASS.
+
+- [ ] **Step 6: Run the whole suite and commit**
+
+```bash
+./scripts/run-node-tests.sh
+git add src/webserver/api/form_renderer.hpp src/webserver/api/form_renderer.cpp \
+        src/webserver/api/webhook_controller.cpp src/webserver/api/webhook_controller.hpp \
+        CMakeLists.txt scripts/verify-node.py tests/nodes/form-render.json tests/nodes/form-render-escaping.json
+git commit -m "feat: serve a form-trigger workflow's form as HTML"
+```
+
+---
+
+### Task 4: Accept the submission
+
+Parse a POST into trigger data, converting uploads into the binary object the rest of the platform already understands.
+
+**Files:**
+- Modify: `src/webserver/api/webhook_controller.cpp`, `src/webserver/api/webhook_controller.hpp`
+- Test: `tests/nodes/form-submit.json`, `tests/nodes/form-submit-required.json`
+
+**Interfaces:**
+- Consumes: `findFormNode` and `form_renderer` from Task 3.
+- Produces: `bool buildFormTriggerData(const httplib::Request&, const nlohmann::json& config, nlohmann::json& out, std::string& error)` - returns false with a human-readable `error` when validation fails. Task 5 calls it before dispatching.
+
+- [ ] **Step 1: Write the failing tests**
+
+The harness cannot send multipart yet. Add a `multipart` branch to `run_http_case` in `scripts/verify-node.py`:
+
+```python
+    multipart = spec.get("multipart")
+    if multipart:
+        boundary = "----smartboticverify"
+        parts = []
+        for name, value in multipart.get("fields", {}).items():
+            parts.append(
+                '--%s\r\nContent-Disposition: form-data; name="%s"\r\n\r\n%s\r\n' % (boundary, name, value))
+        for name, f in multipart.get("files", {}).items():
+            # A tiny real PNG, so the payload is genuinely binary rather than text
+            # that happens to be labelled as an image.
+            blob = base64.b64decode(
+                "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
+            head = ('--%s\r\nContent-Disposition: form-data; name="%s"; filename="%s"\r\n'
+                    'Content-Type: %s\r\n\r\n' % (boundary, name, f.get("filename", "upload.png"),
+                                                  f.get("contentType", "image/png")))
+            parts.append(head.encode() + blob + b"\r\n")
+        payload = b"".join(p.encode() if isinstance(p, str) else p for p in parts)
+        payload += ("--%s--\r\n" % boundary).encode()
+        req = urllib.request.Request(url, data=payload, method="POST")
+        req.add_header("Content-Type", "multipart/form-data; boundary=%s" % boundary)
+```
+
+Import `base64` at the top of the file. This branch replaces the JSON request construction when `multipart` is present.
+
+`tests/nodes/form-submit.json` - an upload must reach the workflow as a binary object with the right mime type and a non-zero size:
+
+```json
+{
+  "name": "verify-form-submit",
+  "nodes": [
+    {"id": "n1", "name": "Form", "type": "form-trigger", "position": {"x": 0, "y": 0},
+     "config": {"title": "Upload", "responseMode": "wait",
+                "fields": [
+                  {"name": "caption", "label": "Caption", "type": "text"},
+                  {"name": "image", "label": "Image", "type": "file", "required": true}
+                ]}},
+    {"id": "n2", "name": "Respond", "type": "respond-to-webhook", "position": {"x": 0, "y": 100},
+     "config": {"status": 200, "bodySource": "json",
+                "body": "{\"caption\": \"{{$node['Form'].form.caption}}\", \"mime\": \"{{$node['Form'].form.image.mimeType}}\", \"kind\": \"{{$node['Form'].form.image.type}}\", \"size\": {{$node['Form'].form.image.size}}}",
+                "contentType": "application/json"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"}
+  ],
+  "http": {
+    "method": "POST",
+    "path": "/webhook/{workflowId}",
+    "multipart": {"fields": {"caption": "a small square"},
+                  "files": {"image": {"filename": "square.png", "contentType": "image/png"}}},
+    "expectStatus": 200,
+    "expectBodyContains": ["\"caption\":\"a small square\"", "\"mime\":\"image/png\"", "\"kind\":\"binary\""],
+    "expectBodyExcludes": ["\"size\":0"]
+  }
+}
+```
+
+`tests/nodes/form-submit-required.json` - a missing required answer is refused and the form comes back with the reason, rather than starting a run with a hole in it:
+
+```json
+{
+  "name": "verify-form-submit-required",
+  "nodes": [
+    {"id": "n1", "name": "Form", "type": "form-trigger", "position": {"x": 0, "y": 0},
+     "config": {"title": "Upload",
+                "fields": [{"name": "image", "label": "Image", "type": "file", "required": true}]}}
+  ],
+  "connections": [],
+  "http": {
+    "method": "POST",
+    "path": "/webhook/{workflowId}",
+    "multipart": {"fields": {}, "files": {}},
+    "expectStatus": 400,
+    "expectBodyContains": ["Image", "required"]
+  }
+}
+```
+
+- [ ] **Step 2: Run them and watch them fail**
+
+```bash
+python3 scripts/verify-node.py tests/nodes/form-submit.json
+python3 scripts/verify-node.py tests/nodes/form-submit-required.json
+```
+Expected: both FAIL - the multipart body is not parsed, so `form.image` is absent.
+
+- [ ] **Step 3: Build the trigger data**
+
+Declare in `webhook_controller.hpp`:
+
+```cpp
+    bool buildFormTriggerData(const httplib::Request& req, const nlohmann::json& config,
+                              nlohmann::json& out, std::string& error);
+```
+
+In `webhook_controller.cpp` - note `StringUtils::base64Encode` and the SHA-256 helper are the same ones the node runtime uses, so the object matches what `http-request` produces byte for byte:
+
+```cpp
+bool WebhookController::buildFormTriggerData(const httplib::Request& req,
+                                             const nlohmann::json& config,
+                                             nlohmann::json& out, std::string& error) {
+    nlohmann::json form = nlohmann::json::object();
+    const auto fields = config.value("fields", nlohmann::json::array());
+
+    for (const auto& f : fields) {
+        const std::string name = f.value("name", "");
+        if (name.empty()) continue;
+        const std::string type = f.value("type", "text");
+        const std::string label = f.value("label", name);
+        const bool required = f.value("required", false);
+
+        if (type == "file") {
+            if (!req.has_file(name)) {
+                if (required) {
+                    error = label + " is required.";
+                    return false;
+                }
+                continue;
+            }
+            const auto file = req.get_file_value(name);
+
+            // A per-field ceiling under the server-wide one from Task 2. The
+            // server limit has already refused anything larger than itself, so
+            // this only tightens, never loosens.
+            const double max_mb = f.value("maxSizeMb", 0.0);
+            if (max_mb > 0 && static_cast<double>(file.content.size()) > max_mb * 1024 * 1024) {
+                error = label + " is larger than the " + std::to_string(static_cast<int>(max_mb)) +
+                        " MB limit for this field.";
+                return false;
+            }
+
+            const std::string b64 = common::StringUtils::base64Encode(file.content);
+            form[name] = {
+                {"type", "binary"},
+                {"data", b64},
+                {"mimeType", file.content_type.empty() ? "application/octet-stream" : file.content_type},
+                {"filename", file.filename},
+                {"size", file.content.size()},
+                {"checksum", common::StringUtils::sha256Hex(b64)}
+            };
+            continue;
+        }
+
+        std::string value;
+        if (req.has_param(name.c_str())) value = req.get_param_value(name.c_str());
+
+        if (type == "checkbox") {
+            form[name] = !value.empty();
+            continue;
+        }
+        if (value.empty()) {
+            if (required) {
+                error = label + " is required.";
+                return false;
+            }
+            form[name] = (type == "number") ? nlohmann::json(nullptr) : nlohmann::json("");
+            continue;
+        }
+        if (type == "number") {
+            try {
+                form[name] = std::stod(value);
+            } catch (const std::exception&) {
+                error = label + " must be a number.";
+                return false;
+            }
+            continue;
+        }
+        form[name] = value;
+    }
+
+    out = nlohmann::json::object();
+    out["form"] = form;
+    out["submittedAt"] = TimeUtils::nowMs();
+    out["clientIp"] = req.remote_addr;
+    return true;
+}
+```
+
+`StringUtils::base64Encode` exists (`lib/common/string_utils.hpp:52`), but there is **no shared `sha256Hex`** - the only implementation is a file-static `sha256Hash` in `src/runner/engine/script_engine.cpp:84`, which the webserver cannot reach. Add one to `lib/common/string_utils.{hpp,cpp}`:
+
+```cpp
+    /// Hex SHA-256 of the given bytes.
+    ///
+    /// A binary object's `checksum` is the hash of its base64 text, not of the
+    /// raw bytes - `http-request.js:457` does `sha256(base64Data)`. An upload
+    /// and a download of the same image must produce the same checksum or
+    /// deduplication downstream silently stops working, so hash the same input.
+    static std::string sha256Hex(std::string_view data);
+```
+
+implemented with `EVP_DigestInit_ex(ctx, EVP_sha256(), nullptr)` as `script_engine.cpp:84` does. Do not inline OpenSSL into the controller.
+
+Verify the two agree before moving on: upload an image through the form and download the same file through an `http-request` node, then compare the `checksum` on each. They must be identical.
+
+- [ ] **Step 4: Call it from the POST path**
+
+Extend the form branch added in Task 3, after the GET handling:
+
+```cpp
+        if (req.method == "POST") {
+            nlohmann::json form_data;
+            std::string error;
+            if (!buildFormTriggerData(req, form_config, form_data, error)) {
+                res.status = 400;
+                const std::string action = "/webhook/" + workflow_id + path;
+                res.set_content(form_renderer::renderForm(form_config, action, error),
+                                "text/html; charset=utf-8");
+                return;
+            }
+            form_trigger_data = form_data;  // consumed below, in place of the JSON body
+        }
+```
+
+Declare `nlohmann::json form_trigger_data;` above the branch, and where `trigger_data` is assembled (around line 128) merge it in rather than parsing the body:
+
+```cpp
+    if (!form_trigger_data.is_null()) {
+        trigger_data["form"] = form_trigger_data["form"];
+        trigger_data["submittedAt"] = form_trigger_data["submittedAt"];
+    } else if (!req.body.empty()) {
+        ...existing JSON body parsing...
+    }
+```
+
+- [ ] **Step 5: Rebuild, restart, verify**
+
+```bash
+cmake --build build -j$(nproc) && systemctl --user restart smartbotic-webserver && sleep 5
+python3 scripts/verify-node.py tests/nodes/form-submit.json
+python3 scripts/verify-node.py tests/nodes/form-submit-required.json
+```
+Expected: both PASS.
+
+- [ ] **Step 6: Run the whole suite and commit**
+
+```bash
+./scripts/run-node-tests.sh
+git add src/webserver/api/webhook_controller.cpp src/webserver/api/webhook_controller.hpp \
+        scripts/verify-node.py tests/nodes/form-submit.json tests/nodes/form-submit-required.json
+git commit -m "feat: accept a form submission, uploads included"
+```
+
+---
+
+### Task 5: Response modes
+
+`immediate` returns the thank-you page at once; `wait` keeps today's blocking behaviour.
+
+**Files:**
+- Modify: `src/webserver/api/webhook_controller.cpp`
+- Test: `tests/nodes/form-immediate.json`
+
+**Interfaces:**
+- Consumes: `buildFormTriggerData` from Task 4, `renderMessage` from Task 3.
+- Produces: nothing new.
+
+- [ ] **Step 1: Write the failing test**
+
+A form whose workflow sleeps well past the webhook timeout must still answer immediately:
+
+```json
+{
+  "name": "verify-form-immediate",
+  "nodes": [
+    {"id": "n1", "name": "Form", "type": "form-trigger", "position": {"x": 0, "y": 0},
+     "config": {"title": "Slow job", "responseMode": "immediate",
+                "responseMessage": "Started, check back later.",
+                "fields": [{"name": "note", "label": "Note", "type": "text"}]}},
+    {"id": "n2", "name": "Slow", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "const end = Date.now() + 40000; while (Date.now() < end) {} return { done: true };",
+                "timeout": 60}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"}
+  ],
+  "http": {
+    "method": "POST",
+    "path": "/webhook/{workflowId}",
+    "multipart": {"fields": {"note": "hello"}, "files": {}},
+    "expectStatus": 200,
+    "expectBodyContains": ["Started, check back later."]
+  }
+}
+```
+
+The harness times out at 45 seconds, so a response that waited for this 40-second run would either fail the timeout or arrive far too late to contain the message.
+
+- [ ] **Step 2: Run it and watch it fail**
+
+```bash
+python3 scripts/verify-node.py tests/nodes/form-immediate.json
+```
+Expected: FAIL - the request blocks for the whole run and does not return the thank-you page.
+
+- [ ] **Step 3: Dispatch without waiting**
+
+Where the gRPC request is built (around `webhook_controller.cpp:166`):
+
+```cpp
+    // A form set to answer immediately must not hold the browser for the run.
+    // The slot claimed above is still released correctly: the runner reports
+    // execution.completed/failed/cancelled/waiting to
+    // /api/v1/internal/execution-event, and execution_controller.cpp:632
+    // releases it there - the same path fire-and-forget scheduled dispatch
+    // relies on today.
+    const bool wait_for_run = form_trigger_data.is_null() || form_response_mode != "immediate";
+    grpc_req.set_wait_for_completion(wait_for_run);
+    grpc_req.set_timeout_ms(wait_for_run ? 30000 : 0);
+```
+
+Set `form_response_mode` from the config in the form branch:
+
+```cpp
+        form_response_mode = form_config.value("responseMode", "immediate");
+```
+
+And after the gRPC call returns, before the existing result handling:
+
+```cpp
+    if (!form_trigger_data.is_null() && form_response_mode == "immediate") {
+        res.status = 200;
+        res.set_content(form_renderer::renderMessage(
+                            form_config_title,
+                            form_response_message),
+                        "text/html; charset=utf-8");
+        return;
+    }
+```
+
+Capture `form_config_title` and `form_response_message` in the form branch, defaulting the message to `"Thanks, your answer was received."`.
+
+- [ ] **Step 4: Rebuild, restart, verify both modes**
+
+```bash
+cmake --build build -j$(nproc) && systemctl --user restart smartbotic-webserver && sleep 5
+python3 scripts/verify-node.py tests/nodes/form-immediate.json
+python3 scripts/verify-node.py tests/nodes/form-submit.json
+```
+Expected: both PASS. The second is the `wait` mode case from Task 4 - it must still block and return the workflow's own response, or this change broke it.
+
+- [ ] **Step 5: Confirm the scheduler slot was released**
+
+An immediate-mode run that leaks its slot would stall the workflow's schedule for up to an hour, and no assertion above would notice:
+
+```bash
+journalctl --user -u smartbotic-webserver --since "3 minutes ago" | grep -i "slot\|deadline" | tail -20
+```
+Expected: no "exceeded its deadline" for the test workflow.
+
+- [ ] **Step 6: Run the whole suite and commit**
+
+```bash
+./scripts/run-node-tests.sh
+git add src/webserver/api/webhook_controller.cpp tests/nodes/form-immediate.json
+git commit -m "feat: a form can answer at once instead of holding the browser"
+```
+
+---
+
+### Task 6: The form password
+
+**Files:**
+- Modify: `src/webserver/api/webhook_controller.cpp`, `src/webserver/api/webhook_controller.hpp`
+- Test: `tests/nodes/form-password.json`
+
+**Interfaces:**
+- Consumes: `renderPasswordPrompt` from Task 3.
+- Produces: `std::string formCookieToken(const std::string& workflow_id, int64_t expires_at)` and `bool formCookieValid(const httplib::Request&, const std::string& workflow_id)`.
+
+- [ ] **Step 1: Write the failing test**
+
+```json
+{
+  "name": "verify-form-password",
+  "nodes": [
+    {"id": "n1", "name": "Form", "type": "form-trigger", "position": {"x": 0, "y": 0},
+     "config": {"title": "Private form", "password": "hunter2",
+                "fields": [{"name": "note", "label": "Note", "type": "text"}]}}
+  ],
+  "connections": [],
+  "http": {
+    "method": "GET",
+    "path": "/webhook/{workflowId}",
+    "expectStatus": 200,
+    "expectBodyContains": ["This form is protected", "__form_password"],
+    "expectBodyExcludes": ["hunter2", "name=\"note\""]
+  }
+}
+```
+
+The exclusions matter as much as the inclusions: the prompt must not leak the password into the page, and must not render the real fields behind it.
+
+- [ ] **Step 2: Run it and watch it fail**
+
+```bash
+python3 scripts/verify-node.py tests/nodes/form-password.json
+```
+Expected: FAIL - the form renders regardless of the password.
+
+- [ ] **Step 3: Implement the cookie and the gate**
+
+```cpp
+std::string WebhookController::formCookieToken(const std::string& workflow_id, int64_t expires_at) {
+    // A signed token rather than the password: the password never reaches the
+    // browser, and a stolen cookie opens one form until it expires.
+    const std::string payload = workflow_id + ":" + std::to_string(expires_at);
+    return payload + ":" + jwt_utils_.signDetached(payload);
+}
+
+bool WebhookController::formCookieValid(const httplib::Request& req, const std::string& workflow_id) {
+    const std::string cookie_header = req.get_header_value("Cookie");
+    const std::string key = "sb_form_" + workflow_id + "=";
+    const auto at = cookie_header.find(key);
+    if (at == std::string::npos) return false;
+
+    std::string value = cookie_header.substr(at + key.size());
+    const auto end = value.find(';');
+    if (end != std::string::npos) value = value.substr(0, end);
+
+    const auto first = value.find(':');
+    const auto second = value.rfind(':');
+    if (first == std::string::npos || second == first) return false;
+
+    const std::string payload = value.substr(0, second);
+    const std::string signature = value.substr(second + 1);
+    if (!jwt_utils_.verifyDetached(payload, signature)) return false;
+
+    const std::string id = payload.substr(0, first);
+    if (id != workflow_id) return false;
+
+    int64_t expires_at = 0;
+    try {
+        expires_at = std::stoll(payload.substr(first + 1));
+    } catch (const std::exception&) {
+        return false;
+    }
+    return TimeUtils::nowMs() < expires_at;
+}
+```
+
+`JwtUtils::sign` and `verifySignature` are private (`jwt_utils.hpp:64-65`). Expose them as public `signDetached` / `verifyDetached` wrappers rather than copying the HMAC code - one signing implementation, one secret. Make `verifyDetached` compare with `CRYPTO_memcmp` rather than `==`, so the comparison is constant-time as the spec requires; `verifySignature` uses `==` today and should be switched too.
+
+`WebhookController` needs a `JwtUtils&` member. Pass it in from `webserver_service.cpp:391` where the controller is constructed.
+
+In the form branch, before rendering or submitting:
+
+```cpp
+        const std::string form_password = form_config.value("password", "");
+        if (!form_password.empty() && !formCookieValid(req, workflow_id)) {
+            const std::string action = "/webhook/" + workflow_id + path;
+            std::string attempt;
+            if (req.method == "POST" && req.has_param("__form_password")) {
+                attempt = req.get_param_value("__form_password");
+            }
+            if (!attempt.empty() && attempt.size() == form_password.size() &&
+                CRYPTO_memcmp(attempt.data(), form_password.data(), attempt.size()) == 0) {
+                const int64_t expires_at = TimeUtils::nowMs() + 3600 * 1000;
+                res.set_header("Set-Cookie",
+                               "sb_form_" + workflow_id + "=" + formCookieToken(workflow_id, expires_at) +
+                               "; Path=/webhook/" + workflow_id + "; HttpOnly; SameSite=Lax; Max-Age=3600");
+                res.status = 200;
+                res.set_content(form_renderer::renderForm(form_config, action, ""),
+                                "text/html; charset=utf-8");
+                return;
+            }
+            res.status = 200;
+            // Deliberately generic: a message naming the form would confirm that
+            // one exists at this URL to somebody guessing.
+            res.set_content(form_renderer::renderPasswordPrompt(
+                                action, attempt.empty() ? "" : "That did not work."),
+                            "text/html; charset=utf-8");
+            return;
+        }
+```
+
+- [ ] **Step 4: Rebuild, restart, verify both directions**
+
+```bash
+cmake --build build -j$(nproc) && systemctl --user restart smartbotic-webserver && sleep 5
+python3 scripts/verify-node.py tests/nodes/form-password.json
+```
+Expected: PASS.
+
+Now check the gate opens as well as closes - a password that never accepts would pass the test above. Create the same workflow by hand, POST the right password, and confirm the fields appear:
+
+```bash
+TOKEN=$(curl -s http://localhost:8090/api/v1/auth/login -H "Content-Type: application/json" \
+  -d '{"username": "admin", "password": "admin"}' | jq -r '.accessToken')
+# create a form workflow with password hunter2, publish and activate it, note its id as WF
+curl -s -X POST "http://localhost:8090/api/v1/../webhook/$WF" \
+  --data-urlencode "__form_password=hunter2" -i | head -20
+```
+Expected: a `Set-Cookie: sb_form_...` header and a body containing the real field.
+
+Also confirm a form with no password still renders directly:
+```bash
+python3 scripts/verify-node.py tests/nodes/form-render.json
+```
+Expected: PASS.
+
+- [ ] **Step 5: Run the whole suite and commit**
+
+```bash
+./scripts/run-node-tests.sh
+git add src/webserver/api/webhook_controller.cpp src/webserver/api/webhook_controller.hpp \
+        src/webserver/auth/jwt_utils.cpp src/webserver/auth/jwt_utils.hpp \
+        src/webserver/webserver_service.cpp tests/nodes/form-password.json
+git commit -m "feat: an optional password on a shared form"
+```
+
+---
+
+### Task 7: The form URL in the editor
+
+The field editor needs no new component: `ArrayFieldEditor.tsx` builds rows from `prop.items.properties`, which Task 1 declared. **Verify that before writing any code** - if it renders, this task is only the URL.
+
+**Files:**
+- Modify: `webui/src/components/workflow/NodeConfigModal.tsx`
+
+- [ ] **Step 1: Check the field editor renders**
+
+Open a workflow, add a Form node, open its settings. The Fields setting should already show a row per entry with Name, Label, Type, Required, Placeholder, Options, Accept and Max Size controls.
+
+If it does not, fix `ArrayFieldEditor` to handle the item schema rather than writing a form-specific editor - every other array-valued setting benefits from the same fix.
+
+- [ ] **Step 2: Show the URL**
+
+In `NodeConfigModal.tsx`, when the node type is `form-trigger`, render the form's address above the settings with a copy button:
+
+```tsx
+{node.type === 'form-trigger' && (
+  <div className="mb-4 rounded border border-gray-200 bg-gray-50 p-3">
+    <div className="text-xs font-medium text-gray-600">Form address</div>
+    <div className="mt-1 flex items-center gap-2">
+      <code className="flex-1 truncate text-sm">{formUrl}</code>
+      <button type="button" onClick={() => navigator.clipboard.writeText(formUrl)}
+              className="rounded border px-2 py-1 text-xs">Copy</button>
+    </div>
+    <p className="mt-2 text-xs text-gray-500">
+      Anyone with this address can open the form once the workflow is published and active.
+    </p>
+  </div>
+)}
+```
+
+with `const formUrl = `${window.location.origin}/webhook/${workflowId}${config.path || ''}``.
+
+The note is not decoration: publish-and-active is exactly what `handleWebhook` requires, and a form that silently 404s because the workflow was never published is the first thing somebody will hit.
+
+- [ ] **Step 3: Verify in the browser**
+
+```bash
+cd webui && npm run lint && npm run build
+```
+Then load a workflow with a Form node, copy the URL, open it in a new tab, and confirm the form renders.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add webui/src/components/workflow/NodeConfigModal.tsx
+git commit -m "feat: show a form's address in the node editor"
+```
+
+---
+
+### Task 8: Extract the anime chain into a sub-workflow
+
+The ten nodes from `Vision Analyse` to `Publish To Blog` were checked for references outside themselves - `$node[...]`, `data.loop.*`, and any mention of the feed - and there are none. The only input is the image.
+
+**Files:**
+- Create: `deploy/workflows/swf-anime-process-one-image.json`
+- Modify: `deploy/workflows/35photo2anime-sdcpp.json`
+- Live: the workflows in the database, via the API
+
+**Interfaces:**
+- Consumes: nothing from earlier tasks.
+- Produces: sub-workflow `[SWF] anime: process one image` with a `workflow-input` node declaring one required field, `file` (binary). Task 9 calls it.
+
+- [ ] **Step 1: Take a backup before touching a live pipeline**
+
+```bash
+TOKEN=$(curl -s http://localhost:8090/api/v1/auth/login -H "Content-Type: application/json" \
+  -d '{"username": "admin", "password": "admin"}' | jq -r '.accessToken')
+curl -s "http://localhost:8090/api/v1/workflows/wf_e6140002-c6b1-428c-afab-68a301591ce6" \
+  -H "Authorization: Bearer $TOKEN" > /tmp/35photo-before.json
+test -s /tmp/35photo-before.json && echo "backup ok"
+```
+
+- [ ] **Step 2: Build the sub-workflow**
+
+Copy the ten nodes and the connections among them out of `/tmp/35photo-before.json` into a new workflow named `[SWF] anime: process one image`, and put a `workflow-input` node in front of `Vision Analyse`:
+
+```json
+{"id": "node_input", "name": "Workflow Input", "type": "workflow-input",
+ "position": {"x": 0, "y": 0},
+ "config": {"allowExtra": true,
+            "fields": [{"name": "file", "required": true,
+                        "description": "The image, as the binary object a download or an upload produces"}]}}
+```
+
+connected to `Vision Analyse` with `{"sourceNodeId": "node_input", "sourceOutput": "main", "targetNodeId": "node_ollama_vision", "targetInput": "data"}`.
+
+Create it, then publish and activate it - `call-workflow` runs the published version, so an unpublished sub-workflow fails at the call.
+
+- [ ] **Step 3: Prove the sub-workflow works before changing anything that runs**
+
+Call it on its own with a known image, exactly as Task 9's form will:
+
+```bash
+# Any of the generated images from a recent run serves as a fixture.
+curl -s -X POST "http://localhost:8090/api/v1/workflows/$SWF_ID/execute" \
+  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
+  -d '{"triggerData": {"file": {"type": "binary", "data": "'"$(curl -s http://mulan:8077/output/806940f5-1a0f-46b1-8179-a3154d2c4e92/output_0.png | base64 -w0)"'", "mimeType": "image/png", "filename": "t.png"}}}'
+```
+Expected: a completed run that produces a post. **Do not go on until this passes** - the next step edits the working pipeline.
+
+- [ ] **Step 4: Point the RSS loop at it**
+
+In `35photo2anime - sdcpp`, delete the ten extracted nodes and connect `Fetch Image` to a new `call-workflow` node:
+
+```json
+{"id": "node_call_process", "name": "Process Image", "type": "call-workflow",
+ "position": {"x": 3540, "y": 1200},
+ "config": {"workflowId": "<the sub-workflow id>", "inputSource": "fields",
+            "fields": [{"name": "file", "value": "{{$node['Fetch Image'].file}}"}]}}
+```
+
+- [ ] **Step 5: Verify the RSS path still produces a post**
+
+Trigger the pipeline by its Click Trigger and watch a full run:
+
+```bash
+curl -s -X POST "http://localhost:8090/api/v1/workflows/wf_e6140002-c6b1-428c-afab-68a301591ce6/execute" \
+  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
+```
+Expected: a completed run ending in a published post, as before the change. If it does not, restore from `/tmp/35photo-before.json` with a PUT and diagnose before retrying.
+
+- [ ] **Step 6: Sync the repo copies and commit**
+
+Export both workflows to `deploy/workflows/`, redacting any Nextcloud token as the existing files do.
+
+```bash
+git add deploy/workflows/
+git commit -m "refactor: one copy of the anime chain, called by the RSS loop"
+```
+
+---
+
+### Task 9: The upload form
+
+**Files:**
+- Create: `deploy/workflows/anime-from-upload.json`
+
+- [ ] **Step 1: Build the workflow**
+
+Two nodes. The password is set deliberately: this form publishes to a live blog and there is no captcha.
+
+```json
+{
+  "name": "anime from upload",
+  "active": true,
+  "nodes": [
+    {"id": "n_form", "name": "Form", "type": "form-trigger", "position": {"x": 0, "y": 0},
+     "config": {"title": "Turn a photo into an anime post",
+                "description": "Upload a picture. It goes through the same steps as one from the feed, and is published when it is done.",
+                "password": "PASTE_THE_GENERATED_ONE",
+                "responseMode": "immediate",
+                "responseMessage": "Thanks - the image is being processed. The post appears on the blog in a few minutes.",
+                "fields": [{"name": "image", "label": "Image", "type": "file",
+                            "required": true, "accept": "image/*", "maxSizeMb": 16}]}},
+    {"id": "n_process", "name": "Process Image", "type": "call-workflow", "position": {"x": 300, "y": 0},
+     "config": {"workflowId": "<the sub-workflow id from Task 8>", "inputSource": "fields",
+                "fields": [{"name": "file", "value": "{{$node['Form'].form.image}}"}]}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n_form", "sourceOutput": "main", "targetNodeId": "n_process", "targetInput": "data"}
+  ],
+  "settings": {}
+}
+```
+
+Generate the password first and give it to the user, since only they can decide who receives it:
+
+```bash
+openssl rand -base64 12
+```
+
+Create the workflow, publish it, activate it.
+
+- [ ] **Step 2: Submit a real image through the browser**
+
+Open `http://localhost:8090/webhook/<workflow id>`, enter the password, choose a photograph, submit.
+
+Expected: the thank-you page returns at once, and within a few minutes a post appears on the blog. Compare it against a post the RSS path produced - same steps means the same shape of result.
+
+- [ ] **Step 3: Check the 18+ flag on the result**
+
+The upload path reaches the same rating step, so it inherits today's fix. Confirm on the new post:
+
+```bash
+curl -s "http://localhost:8090/api/v1/executions/<the run>" -H "Authorization: Bearer $TOKEN" \
+  | python3 -c "import json,sys; e=json.loads(sys.stdin.read(),strict=False)['execution']; \
+    print([ (n['nodeId'], (n.get('output') or {}).get('json')) for n in e['nodeExecutions'] if n['nodeId']=='node_blog_rate'])"
+```
+Expected: a `{nudity, visible, sexualActivity, ...}` verdict, and `is_adult` set from it - not from a low neckline.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add deploy/workflows/anime-from-upload.json
+git commit -m "feat: an upload form into the anime pipeline"
+```
+
+---
+
+### Task 10: Document it
+
+**Files:**
+- Modify: `docs/nodes.md`
+
+- [ ] **Step 1: Write the section**
+
+Add a "Form trigger" section covering: the URL shape (`/webhook/{workflowId}`), that the workflow must be **published and active**, the field types, how a file arrives (`form.<name>` as a binary object, the same shape a download produces), the two response modes and why a long job needs `immediate`, and the password's real strength - stored readable in the workflow, a gate against a forwarded link rather than a secret.
+
+State plainly that there is no captcha, so a public form can be submitted repeatedly.
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add docs/nodes.md
+git commit -m "docs: the form trigger"
+```
+
+---
+
+## Self-review notes
+
+Checked against the spec:
+
+- Part 1 (node) - Task 1. Part 2 (serving) - Tasks 3 and 4. Part 3 (upload limits) - Task 2, plus the per-field check in Task 4. Part 4 (access) - Task 6. Part 5 (response modes) - Task 5. Part 6 (anime variation) - Tasks 8 and 9. Part 7 (WebUI) - Task 7. Verification section - covered by the tests in Tasks 1-6 and the manual runs in Tasks 8 and 9.
+- The spec's `select`, `checkbox`, `date`, `number`, `textarea` types are all rendered in Task 3 and parsed in Task 4.
+- Names used consistently across tasks: `findFormNode`, `buildFormTriggerData`, `form_renderer::renderForm` / `renderPasswordPrompt` / `renderMessage` / `escapeHtml`, `formCookieToken`, `formCookieValid`, config keys `title` / `description` / `fields` / `password` / `responseMode` / `responseMessage` / `path`, and field keys `name` / `label` / `type` / `required` / `placeholder` / `options` / `accept` / `maxSizeMb`.
+- Two harness extensions are introduced where they are first needed and reused later: `bodyPadBytes` and the GET fix (Tasks 2 and 3), `expectBodyExcludes` (Task 3), `multipart` (Task 4).
+
+One gap worth naming rather than hiding: the spec asks for a test that field-name validation rejects a duplicate or malformed name when the workflow is saved. No task implements that validation, because nothing in the codebase validates node config on save today, and adding a validation layer is a larger change than this feature justifies. A bad name currently produces a field the workflow cannot read, which is visible immediately on the rendered form. Worth doing as its own piece of work.