Преглед изворни кода

docs: design for form triggers and the upload path into the anime pipeline

fszontagh пре 1 месец
родитељ
комит
7e3846c8f5
1 измењених фајлова са 215 додато и 0 уклоњено
  1. 215 0
      docs/superpowers/specs/2026-08-09-form-trigger-design.md

+ 215 - 0
docs/superpowers/specs/2026-08-09-form-trigger-design.md

@@ -0,0 +1,215 @@
+# Form Trigger - Design
+
+Date: 2026-08-09
+
+## Goal
+
+A workflow can publish an HTML form at a shareable URL. Somebody with the link
+fills it in - text, choices, a file - and the submission starts the workflow
+with the answers as trigger data.
+
+The immediate use is the anime pipeline: today an image only enters it from an
+RSS feed, and there is no way to hand it one by hand. The wider use is any
+form a person outside SmartBotic should be able to answer, the way Google Forms
+or n8n's Form Trigger are used.
+
+## What already exists
+
+Checked against the code before this design was written, because most of the
+public surface is already built.
+
+**The public route is there.** `webhook_controller.cpp:33` registers
+`/webhook/{workflowId}{path}` for GET, POST, PUT, DELETE and PATCH. It resolves
+the workflow, refuses one that is not active, runs the **published** version
+(`use_published(true)`), and returns the result as the HTTP body. A
+`respond-to-webhook` node can already set status, headers and body through the
+`_webhookResponse` key.
+
+**Binary already has a shape.** `http-request.js:457` produces
+`{type:'binary', data, mimeType, filename, size, checksum}`, and every node that
+takes an image takes that. A form upload that emits the same object needs no
+downstream change at all - which is the whole reason an uploaded image can walk
+the same path as an RSS one.
+
+**Trigger matching is by method.** `findTriggerNode` maps GET to `get-trigger`,
+POST to `post-trigger`, PUT to `put-trigger` (`webhook_controller.cpp:311`).
+
+## What is missing
+
+**Multipart is not parsed.** `webhook_controller.cpp:135` parses the body as
+JSON and, failing that, keeps it as a `std::string`. An uploaded PNG would
+arrive as a string that is not valid UTF-8. httplib 0.18.3 does expose
+`req.files` and `is_multipart_form_data()`, so this is wiring rather than
+invention.
+
+**Uploads are unbounded.** `CPPHTTPLIB_PAYLOAD_MAX_LENGTH` is
+`(std::numeric_limits<size_t>::max)()` by default and the body is buffered whole
+in memory. Every existing public webhook is therefore a memory-exhaustion
+endpoint, not only the new form ones. This design fixes that because it must,
+not because forms introduced it.
+
+**One node must answer two verbs.** A form is a GET that renders and a POST that
+submits, so the method mapping above cannot express it.
+
+**The webhook path always waits.** `wait_for_completion(true)` with a 30 second
+timeout. The anime pipeline runs for minutes, so a form that waits would time
+out on every submission.
+
+## Part 1 - The `form-trigger` node
+
+`nodes/triggers/form-trigger.js`. Config:
+
+| Key | Meaning |
+| --- | --- |
+| `title`, `description` | Shown at the top of the page |
+| `fields[]` | The form, see below |
+| `password` | Optional. Empty means anyone with the link |
+| `responseMode` | `immediate` or `wait` |
+| `responseMessage` | Shown after submit in `immediate` mode |
+| `path` | Optional suffix, as the other HTTP triggers have |
+
+A field is `{name, label, type, required, placeholder, options[], accept,
+maxSizeMb}`. Types: `text`, `textarea`, `number`, `select`, `checkbox`, `date`,
+`file`.
+
+`name` is the key the workflow reads, so it is restricted to
+`[A-Za-z_][A-Za-z0-9_]*` and must be unique within the form. A duplicate or
+malformed name is a validation error when the workflow is saved, not a surprise
+at submission time.
+
+Trigger data:
+
+```js
+{
+  form: {
+    title: "A quiet street",
+    image: { type: 'binary', data, mimeType, filename, size, checksum }
+  },
+  submittedAt: 1786300000000,
+  clientIp: "192.168.2.31"
+}
+```
+
+Files sit in `form.<fieldName>` beside the text answers rather than in a
+separate `files` map. One namespace, so `$node['Form'].form.image` is the answer
+whatever the field's type.
+
+## Part 2 - Serving the page
+
+In `webhook_controller.cpp`, a form-trigger lookup runs **before** the method
+mapping, since one node answers both verbs.
+
+- **GET** renders the form. If `password` is set and the request carries no
+  valid form cookie, it renders a password prompt instead (see Part 4).
+- **POST** parses `multipart/form-data` (or `application/x-www-form-urlencoded`
+  when the form has no file field), validates, builds the trigger data above,
+  and dispatches.
+
+The HTML is generated server-side and is self-contained: no external stylesheet,
+font or script. That keeps a form working on a network that cannot reach a CDN,
+and keeps the page's behaviour a property of this repo.
+
+**Escaping is the security boundary of this feature.** The form definition is
+authored by a SmartBotic user and the page is served to the public, so every
+interpolated value - title, description, label, placeholder, option, error text,
+previously entered value - is HTML-escaped on the way out. A single unescaped
+label is stored XSS on a public URL.
+
+## Part 3 - Upload limits
+
+`set_payload_max_length()` on the server, bounded and configurable in
+`webserver.json` (default 32 MB). Per-field `maxSizeMb` is checked after
+parsing, and an over-large upload is refused with 413 and a message naming the
+field and the limit.
+
+This applies to every webhook, not only forms.
+
+## Part 4 - Access
+
+`password` empty means anyone with the link, which is the point of a shareable
+form. When set, the GET renders a prompt and the POST requires it.
+
+**It is stored readable in the workflow document**, so anyone who can read the
+workflow can read it. It is a gate against a link that got forwarded, not a
+security boundary, and the node's description will say exactly that rather than
+implying more. Moving it behind a credential reference is a later change if it
+turns out to matter.
+
+Answering the prompt correctly sets a cookie scoped to that form's path,
+holding an HMAC of the form id and an expiry signed with the server's JWT
+secret, valid for one hour. It is a signed token rather than the password
+itself, so the password never sits in the browser and a stolen cookie opens one
+form until it expires. The GET and the POST both accept it, which is what stops
+the prompt appearing twice for one submission.
+
+Comparison is constant-time, and a wrong password re-renders the prompt with a
+generic message - not "wrong password for form X" - so the endpoint does not
+confirm that a form exists at that URL.
+
+## Part 5 - Response modes
+
+**`immediate`** dispatches with `wait_for_completion(false)` and returns the
+thank-you page at once. Required for anything long: the anime pipeline runs for
+minutes against a 30 second webhook timeout.
+
+The scheduler slot accounting in `handleWebhook` assumes the call blocks until
+the run ends (`notifyExecutionStarted` before, release on failure after). The
+non-waiting path must not hold a slot it will never release. It does not have
+to invent anything: the runner already reports back to
+`/api/v1/internal/execution-event`, and `execution_controller.cpp:632` releases
+the slot on `execution.completed`, `.failed`, `.cancelled` and `.waiting`. That
+is precisely how scheduled dispatch, which is fire-and-forget today, gets its
+slot back. `immediate` mode claims the slot the same way and lets that path
+free it.
+
+**`wait`** keeps today's behaviour, so a `respond-to-webhook` node can render a
+real answer for forms that ask a question rather than start a job.
+
+## Part 6 - The anime variation
+
+The ten nodes from `Vision Analyse` to `Publish To Blog` were checked for
+references outside themselves: `$node[...]` references, `data.loop.*` variables,
+and any mention of the feed. There are none. The chain is self-contained and its
+only input is the image.
+
+- New sub-workflow `[SWF] anime: process one image`, input `file` (binary),
+  containing those ten nodes unchanged.
+- `35photo2anime - sdcpp` loop body becomes `Fetch Image → call-workflow`.
+- New workflow `anime from upload`: `form-trigger → call-workflow`, same target.
+
+Both paths then share one copy. A rubric fix like the 18+ one lands in both at
+once, instead of in whichever copy somebody remembered.
+
+**Order matters.** Build the sub-workflow and the form path first and verify an
+upload end to end; switch the RSS loop over last, once the shared copy is proven
+by a real run. The working path changes only after the new one works.
+
+## Part 7 - WebUI
+
+- A field-row editor for `fields[]`, following `ArrayFieldEditor.tsx`, which
+  already edits an array of objects for other nodes.
+- The form's URL shown on the node and copyable, since a URL nobody can find is
+  not shareable.
+
+## Out of scope, deliberately
+
+- No form designer, no layout control, no theming beyond the built-in stylesheet.
+- No multi-page forms and no conditional fields.
+- No captcha. A public form with no captcha can be submitted repeatedly, and for
+  the anime form that means posts on a live blog - so that form ships with a
+  password set. Worth revisiting if forms are used more widely.
+- No submissions view. A submission is an execution and is visible as one.
+- No file-type enforcement beyond the `accept` attribute, which is a browser
+  hint and not a check. A form that must not receive an executable needs a node
+  that looks at the bytes.
+
+## Verification
+
+- Node tests for field validation: required, name format, duplicate names,
+  unknown type, size limit.
+- A test that an over-large body is refused with 413 rather than buffered.
+- Escaping test: a form whose title is `<script>alert(1)</script>` renders it as
+  text.
+- End to end: submit a real image through the form and confirm the run reaches
+  the same sub-workflow the RSS path reaches, producing a post.
+- The RSS path re-run after the switch, confirming it still produces a post.