264 lines
13 KiB
Markdown
264 lines
13 KiB
Markdown
# 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:
|
|
|
|
1. **Bootstrap loader** — hand-minified ES5 script at
|
|
`apps/web/public/embed/widget.js` (~723 bytes gzipped). Reads
|
|
`data-form-key` from its own `<script>` tag, inserts a sandboxed
|
|
iframe and bridges postMessage events to the parent page.
|
|
2. **Embedded form app** — React route at `/embed/forms/:formKey`.
|
|
No auth, no app chrome; renders the form definition + theme inside
|
|
the iframe.
|
|
3. **Submission API** — POST `/api/embed/forms/:formKey/submit` with
|
|
origin allowlist, honeypot, sliding-window rate-limit and optional
|
|
Cloudflare Turnstile verification (feature-flag gated).
|
|
4. **Worker routing + follow-ups** — the submit route stores a
|
|
`FormSubmission` row, then emits `form.submission.process` through
|
|
the outbox dispatcher to the `outbox.form-submission-route` queue.
|
|
The routing handler, bootstrap helper and follow-up planner are
|
|
implemented, tested, and wired from the `worker-general` entrypoint.
|
|
Webhook follow-ups emit `webhook.form-submission.deliver`, which is
|
|
bridged by `worker-mail` from `outbox.webhook` onto the existing
|
|
`toolctrl.email` `deliver-webhook` processor.
|
|
5. **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 by
|
|
`apps/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:
|
|
|
|
1. Tab to the drag-handle on a field. The focus-visible ring confirms
|
|
focus.
|
|
2. Press `Space` to "pick up" the field (announced by the screen-reader
|
|
via dnd-kit's built-in live region).
|
|
3. Use `Arrow Up` / `Arrow Down` to move the field.
|
|
4. Press `Space` again to drop, or `Escape` to 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
|
|
|
|
1. Append a `PaletteItem` in `palette.ts` with a fresh `type` literal
|
|
(extend `FieldTypeSchema` in `packages/form-widget/src/schema.ts`
|
|
first; the inferred Zod type drives both the embed renderer and the
|
|
builder).
|
|
2. Render the new type in `FieldProperties.tsx` (right rail) and in
|
|
the `<PreviewField/>` component inside `PreviewTab.tsx`.
|
|
3. Also extend the canonical embed renderer at
|
|
`apps/web/app/routes/embed.forms.$formKey.tsx` so 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:
|
|
|
|
```html
|
|
<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 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 <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_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.
|