Skip to content

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

  1. 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).

  1. 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.

  1. Open http://localhost:7072/workflows/new (or https://localhost:7072/workflows/new for 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/ (or http://localhost:7080/ when running the form-builder via the explicit dev:http opt-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:

frame-src 'self' https://form-builder.example.com;

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 a Referer header, 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-scripts lets the form-builder Angular bundle execute.
  • allow-same-origin keeps event.origin resolving to the real URL origin (without it the browser substitutes "null" and the host's origin pin would drop every envelope).
  • allow-forms covers 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.origin other than the iframe's origin;
  • envelopes from any event.source other than the iframe's contentWindow;
  • envelopes whose nonce doesn't match the active session;
  • envelopes with a kind outside the guest→host allow-list (ready, change, error);
  • envelopes that fail per-field Object.hasOwn validation (a polluted Object.prototype cannot smuggle inherited values past the gate, and decorated structured-clone objects like Date / Map are 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:

  1. Initial seedWorkflowInputFormJsonFormsComponent builds the hint arrays from the workflow's variables() signal and passes them as [workflowVariables] / [contextTokens] inputs to <app-form-builder-iframe>. The host class (FormBuilderEmbedHost) folds them into the first init payload so the iframe sees them on its first render — no "blank then populated" flash.
  2. Live updates — when the workflow author edits the variables list, the same Angular inputs re-fire. The host class then sends a host:hints:update envelope (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 fresh change emission so the host's view of change.validationIssues reflects 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 with JSON.stringify(_, null, 2).
  • Visual → Designer: parse the iframe's change.formPackage, pull uiSchema, schema, and preview?.values back 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).