Form Builder visual editor (embedded iframe)¶
The Workflow Designer's Input Form tab can embed the standalone
@floh/form-builder-app as an iframe so authors compose
JSON Forms layouts visually instead of pasting uiSchema /
dataSchema JSON. This page describes how that integration works,
how to enable it in dev / staging / prod, and the security
boundary it relies on.
This is the host-side companion to the form-builder app's own
docs/form-builder/embed-protocol.md reference
(which documents the wire format from the guest side).
What the author sees¶
Open the Input Form tab on a workflow and switch the format toggle to JSON Forms. A second toggle appears below it:
- Visual — the embedded form-builder iframe. The default when
the deployment is configured with a non-empty
formBuilderEmbedUrl. - Source — three textareas (UI Schema, Data Schema, Sample Values) — the legacy paste-only path.
Both views read and write the same persisted state on the
workflow definition (inputForm.uiSchema, inputForm.dataSchema,
inputForm.sampleValues). Switching between them at any time is
non-destructive: the visual editor seeds itself from the latest
textarea contents on each mount, and the textareas update on every
valid, dirty edit the iframe emits. Invalid buffers and the
post-init handshake echo (a non-dirty change envelope the iframe
sends to acknowledge init) are intentionally not written
back — without that gate, brand-new workflows would silently flip
from "auto-generated form" to "custom blank form" the first time
the visual editor mounted.
When the deployment has not opted into the visual editor (the default for production builds), the second toggle is hidden and authors stay on the Source view.
How to enable it¶
The integration is gated by a single environment knob:
environment.formBuilderEmbedUrl. It is an absolute embed URL
(scheme + host + port + optional path prefix) for the form-builder
app's SPA — the host page preserves that base path verbatim and
appends ?embed=1&hostOrigin=… at runtime when constructing the
iframe src. Path-hosted deployments (e.g.
https://floh.example.com/form-builder/) MUST keep the path in
this value; stripping it down to the origin would break the
deployment because the iframe would target a 404.
Local development¶
- Start the form-builder app:
pnpm --filter @floh/form-builder-app dev # https://localhost:7080 (HTTPS — default)
# explicit HTTP opt-out:
pnpm --filter @floh/form-builder-app dev:http # http://localhost:7080
Both modes listen on port 7080 (configured in
packages/form-builder-app/angular.json). The HTTPS default
reuses the repo's shared self-signed cert pair
(certs/localhost.crt / certs/localhost.key); the dev
script chains node ../../scripts/generate-certs.mjs --quiet
as its first preflight, so on a fresh clone the cert pair is
created automatically (with one-time trust-store and .env
guidance printed). Run pnpm generate-certs from the repo
root manually only when you want that verbose guidance up
front, or to deliberately regenerate (delete the cert files
first). Root-level shortcut: pnpm dev:form-builder (HTTPS)
or pnpm dev:form-builder:http (HTTP opt-out).
- Start the Floh web SPA:
pnpm --filter @floh/web dev # http://localhost:7072
# or, with TLS:
pnpm --filter @floh/web dev:https # https://localhost:7072
The local dev environment file
(packages/web/src/environments/environment.ts) ships with
formBuilderEmbedUrl: "https://localhost:7080/" so the iframe
targets the sibling port automatically. An https:// iframe
embeds cleanly inside both http:// and https:// parent
pages, so the default works for pnpm dev:web, pnpm dev:https,
and pnpm dev:portal:https alike. The inverse is not true: an
http:// iframe is mixed-content-blocked by an https://
parent. If you opt the form-builder out via dev:http, also
flip formBuilderEmbedUrl back to http://localhost:7080/ for
the duration of the session (otherwise the iframe will point at
a TLS port that no longer answers); revert before committing.
- Open
http://localhost:7072/workflows/new(orhttps://localhost:7072/workflows/newfor HTTPS), switch to the Input Form tab, set Format → JSON Forms, and you see the embedded editor.
Production¶
packages/web/src/environments/environment.prod.ts ships with
formBuilderEmbedUrl: "" so a fresh production build never embeds
a dev-only origin by mistake. Operations teams opt in by overriding
the value at build time:
export const environment = {
production: true,
apiUrl: "/api",
- formBuilderEmbedUrl: "",
+ formBuilderEmbedUrl: "https://form-builder.example.com/",
};
The form-builder app MUST be served cross-origin from the Floh
SPA. The host class throws at construction if
formBuilderEmbedUrl resolves to the same origin as
window.location — same-origin embeds defeat the iframe's
sandbox="allow-scripts allow-same-origin" policy and grant the
form-builder bundle full access to the designer's DOM, cookies, and
localStorage. Cross-origin deployments preserve the browser's
isolation boundary and leave postMessage as the only attack
surface.
Acceptable cross-origin deployment shapes (any of these works — the path under the origin can be whatever you want):
- A dedicated subdomain —
https://forms.floh.example.com/ - A different host entirely —
https://form-builder.example.com/ - A different port on the same host (dev only) —
https://localhost:7080/(orhttp://localhost:7080/when running the form-builder via the explicitdev:httpopt-out)
Same-origin path-hosted deployments (e.g.
https://floh.example.com/form-builder/ while the SPA itself runs
on https://floh.example.com/) are explicitly rejected at
runtime — re-host the form-builder under a distinct origin first.
Browser policies¶
The iframe is loaded cross-origin in development (Floh on
:7072, Form Builder on :7080). Make sure your production CSP
allows the form-builder origin:
The form-builder app must serve permissive Content-Security-Policy
and must not send X-Frame-Options: DENY — see its own
deployment notes for the right header set.
The host also pins two iframe attributes itself:
referrerpolicy="no-referrer"— the iframe never receives aRefererheader, so the form-builder origin cannot deduce the parent workflow id from the URL path.sandbox="allow-scripts allow-same-origin allow-forms"— minimum capability set the embed protocol needs:allow-scriptslets the form-builder Angular bundle execute.allow-same-originkeepsevent.originresolving to the real URL origin (without it the browser substitutes"null"and the host's origin pin would drop every envelope).allow-formscovers the form-builder's<form>-based FormPackage import flow.
Other capabilities (allow-popups, allow-top-navigation,
allow-modals, allow-downloads, allow-pointer-lock, …) are
intentionally not granted: a compromised guest cannot navigate
the parent window or open new tabs against the designer's session.
Wire protocol — what the host sends and receives¶
The host-side controller (FormBuilderEmbedHost in
packages/web/src/app/shared/components/form-builder-iframe/form-builder-embed-host.ts)
implements the host half of the form-builder embed protocol shipped
in @floh/form-builder-app PR #357. The contract is documented end-
to-end in embed-protocol.md; this section only
calls out the host-specific behaviour.
Boot handshake¶
Host page Form Builder iframe
│ │
│ src=…?embed=1&hostOrigin=<host-origin> │
├──────────────────────────────────────────────────────►│
│ │
│ { v:1, kind:"ready", nonce:"r", … } │
│◄──────────────────────────────────────────────────────┤
│ │
│ { v:1, nonce:"r", kind:"init", │
│ payload:{ formPackage:"…JSONC…", context:{…} } } │
├──────────────────────────────────────────────────────►│
The host does NOT send anything before it sees a ready envelope.
A second ready with a fresh nonce (e.g. the iframe was reloaded)
silently re-seeds init against the new nonce and does NOT
re-fire the host-side (ready) event so OnPush parents don't
double-mount.
Change events¶
Every parsed-and-validated edit in the iframe produces:
{
"v": 1,
"nonce": "<active session nonce>",
"kind": "change",
"payload": {
"formPackage": "<JSONC editor buffer text>",
"valid": true, // false ⇒ buffer is invalid
"validationIssues": [], // [] when valid:true OR unparseable
"dirty": true, // first-edit-since-init flag
},
}
The host drops:
- envelopes from any
event.originother than the iframe's origin; - envelopes from any
event.sourceother than the iframe'scontentWindow; - envelopes whose
noncedoesn't match the active session; - envelopes with a
kindoutside the guest→host allow-list (ready,change,error); - envelopes that fail per-field
Object.hasOwnvalidation (a pollutedObject.prototypecannot smuggle inherited values past the gate, and decorated structured-clone objects likeDate/Mapare refused even when they carry the right own keys).
Drops are logged at console.debug with a sanitised metadata
object — never the raw envelope contents — so an attacker-controlled
peer cannot inject log content.
Workflow-variable + context-token hints¶
The host also surfaces the workflow's declared input variables and
a per-workflow catalog of runtime context tokens (submitter.*,
workflow.*, plus targetUser.* only when the workflow's
category is user_self_service so the picker never blesses a
binding that would silently fall back to Display.fallback at
run time) inside the iframe. These hints let the form author
bind a Control's outputVariable to a real workflow variable
(with autocomplete + an "unknown variable" warning chip) and
bind a Display's contextScope to a real runtime token (under
per-source <optgroup>s in the picker) without retyping
identifiers.
Hints flow through two routes:
- Initial seed —
WorkflowInputFormJsonFormsComponentbuilds the hint arrays from the workflow'svariables()signal and passes them as[workflowVariables]/[contextTokens]inputs to<app-form-builder-iframe>. The host class (FormBuilderEmbedHost) folds them into the firstinitpayload so the iframe sees them on its first render — no "blank then populated" flash. - Live updates — when the workflow author edits the variables
list, the same Angular inputs re-fire. The host class then
sends a
host:hints:updateenvelope (versioned and nonce- pinned, same as every other host→guest message) so the iframe refreshes the chip strip / autocomplete / palette section in place. The bridge then forces a freshchangeemission so the host's view ofchange.validationIssuesreflects whether host tokens now resolve a previously-broken Display scope — without the re-emit, a Display bound to a host token would stay flagged as invalid in the host's issue list until the user happened to type into the buffer. Empty arrays clear the corresponding list rather than partially merging — keeping the wire contract symmetric.
When the iframe needs a full reset (Visual ↔ Source toggle,
route remount), the wrapper stages the latest hints via the
send-free stageHostHints() overload and lets
resetWithFormPackage's follow-up init carry them — so
exactly one envelope lands on the wire instead of one
host:hints:update followed by an init carrying the same
snapshot.
The hint shapes are decoupled from Floh's internal
VariableDefinition. The shared @floh/web-shared
buildHostHints helper projects only the design-time-relevant
fields (name, type, required, description) and drops
everything else (defaultValue, secret, etc.) so sensitive
data does not cross the iframe boundary just to populate an
autocomplete. See
embed-protocol.md § Payload shapes for the
exact wire shape and
buildHostHints in packages/web-shared/src/host-hints/build-host-hints.ts
for the projection rules.
Round-trip with the textareas¶
The Workflow Designer's persisted shape is three independent JSON
strings (uiSchema, dataSchema, sampleValues); the form-builder
speaks a single unified FormPackage JSONC document. The
form-package-roundtrip.ts helper translates both directions:
- Designer → Visual: combine the three textareas into a single
package (
{ uiSchema, schema, preview: { values } }) and serialise withJSON.stringify(_, null, 2). - Visual → Designer: parse the iframe's
change.formPackage, pulluiSchema,schema, andpreview?.valuesback out, and re-serialise each into its textarea.
The round-trip is byte-stable for canonical pretty-printed JSON, so flipping back and forth between Source and Visual without editing produces byte-identical textarea content. Comments and trailing commas inside the iframe's Monaco buffer are intentionally dropped on the way back to the textareas — the strict-JSON textareas are the wire format and round-tripping JSONC into a single textarea would split comments unpredictably across the three.
Failure modes¶
| Symptom | Cause | Resolution |
|---|---|---|
| Visual toggle is missing | environment.formBuilderEmbedUrl is "" (default in prod) |
Override the env at build time; redeploy. |
| Iframe shows the form-builder home but never the seeded form | Iframe URL is missing ?embed=1 or hostOrigin=… |
Confirm the configured URL is the SPA root, not a deep-linked editor route. |
| "Source JSON has a syntax error" warn message in Visual | One of the three textareas does not parse as JSON | Switch to Source, fix the textarea the message names, switch back. |
| Iframe stays blank, console shows origin / nonce drops | Cross-origin policy / CSP / X-Frame-Options mismatch, or the form-builder app reloaded mid-handshake |
Verify CSP and X-Frame-Options (DENY blocks the iframe entirely). The host transparently re-seeds across iframe reloads. |
Phase scope¶
Phase 2 of the iframe rollout shipped the designer-side
integration — composing the form package over the embed protocol —
and Phase 3 wired the runtime side: Display.contextScope and
{{ctx.<path>}} resolve against the active workflow run's
submitter / targetUser / prior-step variables, and
Control.outputVariable maps submitted field values back to the
workflow at submit time.
This page now also covers the workflow-variable + context-token hint plumbing (LSA-8645, issue #365) which closes the loop between the workflow's declared variables and the form designer's authoring surfaces (chip strip, autocomplete, context-scope picker, palette section).