# Embeddable Form Widget Tenant-authored forms that other websites can embed via a single ` ``` Optional attributes: - `data-host="https://forms.example.com"` — explicit iframe host. Defaults to the origin of `widget.js` itself. ## Anti-spam layers The submission endpoint applies four layers in order. Each layer either rejects the request or quietly drops it (honeypot). | Layer | Mechanism | Configurable | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------- | | Origin allowlist | `Origin` header matched against `Form.allowedOrigins`. Empty allowlist = no submissions. Supports `*.example.com` wildcards. | per-form | | Honeypot | A hidden field with key `hp_extra_data`. When filled, the API returns 204 silently. | always on | | Rate-limit | Sliding-window via `@toolctrl/shared-store`: 10/min/IP, 100/min/origin. | static | | Turnstile | Cloudflare CAPTCHA, opt-in per form (`config.turnstileEnabled=true`) + feature flag `form-widget-turnstile`. | per-form + global flag | ## Submission targets `Form.targetType` chooses how a submission is processed: - `generic` (default) — the submission is stored immediately; the routing handler marks it `processed`. - `crm-contact` — the routing handler maps submitted fields through `Form.fieldMapping`, calls the injected CRM adapter, and marks the submission `routed-to-crm` with the created contact id. - `entity-row` — the routing handler maps submitted fields through `Form.fieldMapping`, calls the injected entity-row adapter for `targetEntityId`, and marks the submission `routed-to-entity` with the created row id. Routing is asynchronous and idempotent. The handler short-circuits rows that are no longer `received`, rejects tenant mismatches between the outbox envelope, payload, and stored submission row, writes `route-failed` plus a redacted error on failure, and rethrows so BullMQ retries and error reporting still see the issue. `apps/worker/src/index.ts` wires `bootstrapFormSubmissionRoute(...)` on `WORKER_QUEUE_GROUP=general` with the Prisma store, CRM/entity adapters, Redis worker factory, and follow-up publisher. Webhook targets are delivered through a second queue hop: routing emits `webhook.form-submission.deliver`; `WORKER_QUEUE_GROUP=mail` subscribes to `outbox.webhook`, validates the tenant-scoped payload, then enqueues the existing `toolctrl.email` `deliver-webhook` job with an idempotency key based on the outbox event id, dispatch key, or submission id. Notification email follow-ups use the same pattern: routing emits `mail.transactional.form-submission`; `WORKER_QUEUE_GROUP=mail` subscribes to `outbox.mail`, validates the tenant-scoped payload, renders a compact operator notification, then enqueues the existing `toolctrl.email` `send-transactional-email` job. The PII-sensitive submitter IP is never stored raw; only the `sha256(ip).slice(0, 32)` lives in `FormSubmission.ipHash`. ## Theming `Form.theme` accepts a partial of: ```json { "primaryColor": "#2563eb", "backgroundColor": "#ffffff", "textColor": "#111111", "fontFamily": "system-ui, sans-serif", "borderRadius": "6px" } ``` The renderer exposes these as CSS variables `--tcform-primary`, `--tcform-bg`, `--tcform-text`, `--tcform-font`, `--tcform-radius`. Empty theme inherits browser defaults; future work will pull defaults from `TenantSettingsRow` brand tokens. ## postMessage protocol The iframe emits the following messages; the bootstrap loader re-broadcasts them on the host document as `CustomEvent`s of the same name: | Type | Payload | | ------------------ | --------------------------------------------------------------------------------------------------------------- | | `tcform:resize` | `{ height, formKey }` — fires whenever the iframe content reflows. The loader clamps height to `[100, 8000]px`. | | `tcform:submitted` | `{ formKey }` — fires once the submission API returns `ok=true`. | | `tcform:error` | `{ formKey, error }` — fires on validation / 4xx / 5xx. | Listen on the parent page: ```js document.addEventListener("tcform:submitted", (event) => { console.log("submitted!", event.detail); }); ``` ## Webhook payload When `Form.webhookUrl` is set, the follow-up planner emits a `webhook.form-submission.deliver` outbox event after successful routing. The payload shape is: ```json { "submissionId": "ckxxxxxxxxxxxxxxxxxxxxxxx", "formId": "ckxxxxxxxxxxxxxxxxxxxxxxx", "tenantId": "...", "webhookUrl": "https://www.example.com/hooks/form", "targetType": "crm-contact", "targetId": "ckxxxxxxxxxxxxxxxxxxxxxxx", "values": { "email": "alice@example.com", "...": "..." } } ``` When `notifyEmails` is non-empty, the same follow-up planner emits `mail.transactional.form-submission` with `recipients`, `targetType`, `targetId` and `values`. The worker host publishes the returned outbox events transactionally during routing, and `worker-mail` bridges them from `outbox.mail` into `toolctrl.email`. Operator-owned delivery concerns include provider credentials, queue health and any tenant-facing delivery-status UX. URLs are validated against an internal-host blocklist (`localhost`, `127.0.0.1`, `10.x`, `192.168.x`, `169.254.x`, `*.internal`) before they're allowed to save (SSRF defense). ## CLI ``` toolctrl-cli forms list --tenant toolctrl-cli forms create --tenant --name "Contact" --origin https://www.example.com toolctrl-cli forms delete --id ``` ## Environment variables Add to `.env`: | Name | Purpose | | ---------------------- | ----------------------------------------------------------------------------------- | | `FORM_WIDGET_CDN_URL` | Public CDN/host that serves `/embed/widget.js`. Optional — falls back to `APP_URL`. | | `TURNSTILE_SITE_KEY` | Cloudflare Turnstile site key (front-end). Required when Turnstile is enabled. | | `TURNSTILE_SECRET_KEY` | Cloudflare Turnstile server secret. Required when Turnstile is enabled. | ## Troubleshooting - **403 "Origin not allowed"** — add the embedding site's origin to `Form.allowedOrigins`. Browsers send only scheme+host+port; no path. - **204 with no DB row** — the honeypot triggered. Check that automation scripts skip the `hp_extra_data` field. - **429** — the per-IP or per-origin sliding window kicked in. Each window is 60 seconds. - **iframe height stuck** — the parent page is blocking the loader's message listener. Confirm `widget.js` is loaded with `async` and not filtered by a strict CSP. ## Security notes - iframe `sandbox="allow-scripts allow-forms allow-same-origin"` — no storage/popups/top-navigation. - Form definitions are Zod-validated on read; React escapes every text node so XSS via crafted labels is not possible. - IPs are stored as truncated SHA-256 hashes. - Webhook URLs are validated against an internal-host blocklist.