13 KiB
Embeddable Form Widget
Tenant-authored forms that other websites can embed via a single
<script> tag. Submissions land in the tenant inbox and can optionally
route to CRM contacts, entity rows, webhooks or notification emails.
Phase 05 task #58 — spec:
docs/improvement-plan/05-business-features/form-widget.md.
Architecture
Four layers:
- Bootstrap loader — hand-minified ES5 script at
apps/web/public/embed/widget.js(~723 bytes gzipped). Readsdata-form-keyfrom its own<script>tag, inserts a sandboxed iframe and bridges postMessage events to the parent page. - Embedded form app — React route at
/embed/forms/:formKey. No auth, no app chrome; renders the form definition + theme inside the iframe. - Submission API — POST
/api/embed/forms/:formKey/submitwith origin allowlist, honeypot, sliding-window rate-limit and optional Cloudflare Turnstile verification (feature-flag gated). - Worker routing + follow-ups — the submit route stores a
FormSubmissionrow, then emitsform.submission.processthrough the outbox dispatcher to theoutbox.form-submission-routequeue. The routing handler, bootstrap helper and follow-up planner are implemented, tested, and wired from theworker-generalentrypoint. Webhook follow-ups emitwebhook.form-submission.deliver, which is bridged byworker-mailfromoutbox.webhookonto the existingtoolctrl.emaildeliver-webhookprocessor. - Tenant admin — list + create at
/:tenant/forms, edit and submissions inbox at/:tenant/forms/:id. The edit page hosts a visual drag-drop form-builder (see below) backed byapps/web/app/modules/formWidget/builder/.
Drag-drop form-builder
The form-edit page renders three tabs:
| Tab | What you do |
|---|---|
| Builder | Add fields from the left-rail palette (click or drag-to-canvas), reorder them via pointer-drag or keyboard arrow-keys on the focused drag-handle, edit per-field properties in the right-rail panel (label, key, required, placeholder, help-text, min/max length, select/radio options). |
| Settings | allowedOrigins, notifyEmails, webhookUrl, targetType (generic / crm-contact / entity-row) and a field-mapping editor that appears when targetType is anything other than generic. |
| Preview | Live render using the same field components + inline CSS the embedded /embed/forms/:formKey route uses, so what the operator sees is exactly what visitors will see. Submission is disabled in the preview. |
Save persists definition (FormDefinition), theme (FormTheme),
allowedOrigins, notifyEmails, webhookUrl, targetType and
fieldMapping on the parent Form row in a single transaction.
Field palette
Built-in field types (each maps to a FormFieldType):
text, email, textarea, select, checkbox, radio, number,
honeypot. New palette entries can be added in
apps/web/app/modules/formWidget/builder/palette.ts; the defaults
factory must produce a value that passes FormFieldSchema.safeParse.
Keyboard accessibility
Reorder works without a pointer:
- Tab to the drag-handle on a field. The focus-visible ring confirms focus.
- Press
Spaceto "pick up" the field (announced by the screen-reader via dnd-kit's built-in live region). - Use
Arrow Up/Arrow Downto move the field. - Press
Spaceagain to drop, orEscapeto cancel.
Every interactive button on the canvas (drag-handle, select, delete)
exposes a templated aria-label so screen-readers describe each row by
its field-label.
Adding a new field-type
- Append a
PaletteIteminpalette.tswith a freshtypeliteral (extendFieldTypeSchemainpackages/form-widget/src/schema.tsfirst; the inferred Zod type drives both the embed renderer and the builder). - Render the new type in
FieldProperties.tsx(right rail) and in the<PreviewField/>component insidePreviewTab.tsx. - Also extend the canonical embed renderer at
apps/web/app/routes/embed.forms.$formKey.tsxso the customer-facing render matches the preview.
Out-of-scope (Phase-1 deferred)
- Conditional logic (
showIf) on fields - Multi-step / paginated forms
- File-upload fields (requires AV-scan integration from Phase 01 #31)
- Drag-drop Playwright E2E coverage (covered locally by unit tests
over
uniqueKey+ palette factories) - SRI hash on the embed snippet (waits on the CDN build step)
Embedding
Copy the snippet from the form-detail page or write it manually:
<script src="https://forms.example.com/embed/widget.js" data-form-key="<the-form-public-key>" async></script>
Optional attributes:
data-host="https://forms.example.com"— explicit iframe host. Defaults to the origin ofwidget.jsitself.
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 itprocessed.crm-contact— the routing handler maps submitted fields throughForm.fieldMapping, calls the injected CRM adapter, and marks the submissionrouted-to-crmwith the created contact id.entity-row— the routing handler maps submitted fields throughForm.fieldMapping, calls the injected entity-row adapter fortargetEntityId, and marks the submissionrouted-to-entitywith 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:
{
"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 CustomEvents 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:
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:
{
"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 <tenantId>
toolctrl-cli forms create --tenant <tenantId> --name "Contact" --origin https://www.example.com
toolctrl-cli forms delete --id <formId>
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_datafield. - 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.jsis loaded withasyncand 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.