Embedding the Form Builder¶
The standalone form-builder SPA can be loaded by a host page inside an
<iframe> and driven over postMessage. This page is the
integrator's walkthrough — minimum viable host code, hosting setup,
CSP requirements. For the wire format itself, see
Form-Builder Embed Protocol (v1).
For a runnable reference host you can copy from, see
packages/form-builder-app/public/embed-host.html. Run
pnpm --filter @floh/form-builder-app dev and open
https://localhost:7080/embed-host.html in a browser (the dev server
defaults to HTTPS — accept the self-signed cert on first visit). For
the HTTP opt-out, use pnpm --filter @floh/form-builder-app dev:http
and http://localhost:7080/embed-host.html.
Quickstart — minimum viable host¶
<iframe id="form-builder" title="Form builder"></iframe>
<script type="module">
// jsonc-parser is needed to safely strip host-owned
// `preview.context` from the JSONC editor buffer before
// persisting (see the `stripHostContext` helper below).
//
// Production hosts SHOULD bundle jsonc-parser from npm via
// their own build pipeline so the import resolves to a
// first-party URL covered by `script-src 'self'`:
//
// npm install jsonc-parser
// import { modify, applyEdits } from "jsonc-parser";
//
// The cdn.jsdelivr.net URL below is shown so this snippet is
// copy-pasteable into a static .html file with no build step,
// BUT a strict `script-src 'self'` CSP will block it. To allow
// it without bundling, add the CDN to your CSP (e.g.
// `script-src 'self' https://cdn.jsdelivr.net;`). The exact
// version is pinned (`@3.3.1`, NOT `@3`) so the CDN cannot
// silently swap module bodies between page loads — the `@3`
// form floats to the latest 3.x, which is convenient but
// means a compromised CDN account or upstream maintainer
// could ship a new 3.x with arbitrary code. See the CSP
// section for the full threat-model story (in particular,
// why CSP hashes / Subresource-Integrity DO NOT pin module
// bodies fetched via `import`).
import {
applyEdits,
findNodeAtLocation,
modify,
parseTree,
} from "https://cdn.jsdelivr.net/npm/jsonc-parser@3.3.1/+esm";
// 1. Build the iframe URL with embed=1 + the host origin.
const hostOrigin = window.location.origin;
const iframeSrc = new URL("https://forms.example.com/");
iframeSrc.searchParams.set("embed", "1");
iframeSrc.searchParams.set("hostOrigin", hostOrigin);
const iframe = document.getElementById("form-builder");
let sessionNonce = null;
// 2. Listen for messages from the iframe BEFORE assigning src.
const PROTOCOL_VERSION = 1;
window.addEventListener("message", (event) => {
if (event.source !== iframe.contentWindow) return;
if (event.origin !== iframeSrc.origin) return;
const data = event.data;
// Envelope guard: require OWN properties for every field we
// read. A bare `data.v !== PROTOCOL_VERSION` happily picks up
// `Object.prototype.v` if a transitive dependency on the host
// page polluted the prototype chain (the Lodash CVE-2019-10744
// pattern is the famous example), and a malicious peer that
// can pollute the host-page prototype chain BEFORE the iframe
// boots can therefore smuggle a forged envelope through.
// `Object.hasOwn` rejects inherited-only fields, so an envelope
// missing every own field — even one with `Object.prototype.v
// = 1` — falls through to the `return` and is dropped. This
// mirrors `protocol.ts`' isEmbedEnvelope guard one-for-one.
if (
!data ||
typeof data !== "object" ||
Array.isArray(data) ||
!Object.hasOwn(data, "v") ||
!Object.hasOwn(data, "nonce") ||
!Object.hasOwn(data, "kind") ||
!Object.hasOwn(data, "payload") ||
data.v !== PROTOCOL_VERSION ||
typeof data.nonce !== "string" ||
data.nonce.length === 0 ||
typeof data.kind !== "string" ||
data.kind.length === 0
) {
return;
}
// Optional `id` field for ack-style correlation. The protocol
// permits envelopes without `id` (current SPA never sends one),
// but if `id` IS present it MUST be a non-empty string. An
// attacker-controlled own `id: 0` / `id: ""` would otherwise
// pass the envelope guard and reach dispatch with a falsy
// correlator, which integrators that DO use `id` would silently
// mishandle. Mirrors `isEmbedEnvelope`'s optional-id branch in
// protocol.ts one-for-one.
if (Object.hasOwn(data, "id") && (typeof data.id !== "string" || data.id.length === 0)) {
return;
}
// 3. Every `ready` resets the session — initial AND post-reload.
// The guest mints a fresh nonce on every load (HMR refresh,
// iframe.src reassignment, navigation), so a host that pinned
// only the FIRST nonce would silently stop working after any
// reload. Replace the nonce on every ready, then re-send init.
//
// Two checks the harness enforces and your host MUST too:
// a. payload.protocolVersion === PROTOCOL_VERSION — refuse to
// partially-handshake with a future v2 guest you can't
// drive correctly.
// b. data.nonce === data.payload.nonce — the protocol
// invariant is that the envelope nonce and the payload
// nonce match (the guest derives both from one randomUUID
// call). Mismatch indicates a tampered or buggy peer; do
// not store the payload nonce as the session nonce or
// every subsequent send will address the wrong session.
if (data.kind === "ready") {
// Own-property guard on the payload too — same threat
// model as the envelope guard. A polluted host
// `Object.prototype` could otherwise satisfy
// `payload.protocolVersion === 1` and `typeof
// payload.nonce === "string"` from inherited properties
// alone, smuggling a malformed `ready` past the
// narrowing guard. `Object.hasOwn` rejects inherited-
// only fields, so a payload with NO own data falls
// through to the `return`.
const payload = data.payload;
if (
!payload ||
typeof payload !== "object" ||
Array.isArray(payload) ||
!Object.hasOwn(payload, "protocolVersion") ||
!Object.hasOwn(payload, "nonce") ||
payload.protocolVersion !== PROTOCOL_VERSION ||
typeof payload.nonce !== "string" ||
payload.nonce !== data.nonce
) {
return;
}
sessionNonce = payload.nonce;
iframe.contentWindow.postMessage(
{
v: PROTOCOL_VERSION,
nonce: sessionNonce,
kind: "init",
payload: { formPackage: yourInitialFormPackageJsonText },
},
iframeSrc.origin,
);
return;
}
if (data.nonce !== sessionNonce) return;
// 4. Every edit emits `change` with the latest serialised form package.
// A `change` with `dirty: false` carries the host's OWN bytes
// back — it's emitted after the host applies an `init` or
// `context:update` so the host can refresh its issue list with
// re-validated `validationIssues` (e.g. a Display element with
// `contextScope: "/foo"` becomes valid only after `foo` is in
// the context). Persisting on `dirty: false` would round-trip
// volatile runtime context into your saved form definition.
// Gate persistence on `dirty: true`; use the `change` itself
// to keep your validation/dirty UI in sync regardless.
//
// IMPORTANT — stripping host-owned `preview.context` before
// save: the bridge writes the host's `init.context` and every
// `context:update` into the buffer's `preview.context` block
// so the live preview can render against real data. That
// block is part of the bytes returned in `change.formPackage`,
// so a host that persists those bytes verbatim ends up with
// runtime workflow values baked into the saved package — and
// the next session will then start with stale context until
// the host's first `init`/`context:update` overwrites it.
// The host owns the runtime context already, so the safe
// pattern is to drop `preview.context` before persisting and
// re-supply it on the next `init`.
if (data.kind === "change") {
// Per-kind payload guard. The envelope check above only
// proves the wrapper is well-formed; the *payload* shape
// is per-kind and must be re-validated before reading
// fields. A malformed `change` payload that flowed past
// this guard could throw inside the persistence layer
// (`.save(undefined)`) or the validation UI
// (`.refresh(null)`), and a forged-but-envelope-valid
// peer could trip those paths to surface ugly stack
// traces in the host.
//
// Same own-property discipline as the envelope guard:
// every field is checked with `Object.hasOwn` before
// typeof-checking, so a polluted host
// `Object.prototype.formPackage = "..."` (or any of the
// other documented fields) cannot smuggle "valid" data
// into an empty payload. `embed-host.html` runs the same
// narrowing in `isValidPayloadForKind`.
const payload = data.payload;
if (
!payload ||
typeof payload !== "object" ||
Array.isArray(payload) ||
!Object.hasOwn(payload, "formPackage") ||
!Object.hasOwn(payload, "dirty") ||
!Object.hasOwn(payload, "valid") ||
!Object.hasOwn(payload, "validationIssues") ||
typeof payload.formPackage !== "string" ||
typeof payload.dirty !== "boolean" ||
typeof payload.valid !== "boolean" ||
!Array.isArray(payload.validationIssues)
) {
return;
}
// Also narrow each issue. Without this, a payload with a
// non-object entry (BigInt, function, cyclic graph) would
// pass the array check above and only blow up later
// inside `yourValidationUi.refresh` when it walks the
// entries to render. Reject the whole payload up front so
// a single bad entry can't kill the listener for the rest
// of the session.
for (const issue of payload.validationIssues) {
if (
!issue ||
typeof issue !== "object" ||
Array.isArray(issue) ||
!Object.hasOwn(issue, "path") ||
!Object.hasOwn(issue, "message") ||
typeof issue.path !== "string" ||
typeof issue.message !== "string"
) {
return;
}
}
yourValidationUi.refresh(payload);
// Gate persistence on TWO independent invariants:
//
// 1. `payload.dirty === true` — without this we'd persist
// every refresh, including the post-init / post-context-
// update echo where the bytes are the host's own.
//
// 2. `stripped.ok === true` — `stripHostContext` returns
// `ok: false` when the buffer is unparseable JSONC
// (mid-edit unbalanced braces, stray comma, …) OR when
// a `modify` / `applyEdits` call throws. In either case
// the helper falls back to the verbatim buffer, which
// still contains the host-owned `preview.context` block.
// Persisting that verbatim text would defeat the whole
// "host owns runtime context, never persist it" rule
// from the section above. Skip the save instead and
// wait for the next clean change to deliver a
// strippable buffer. (Most authors fix the syntax
// within a few keystrokes; the dirty flag persists
// across the gap.)
if (payload.dirty === true) {
const stripped = stripHostContext(payload.formPackage);
if (stripped.ok) {
yourPersistenceLayer.save(stripped.text);
}
}
}
// 5. Surface guest-side errors. The bridge emits `error`
// envelopes for malformed `init` / `context:update` you
// sent, validator-throws on the loaded form package, and
// a few other guest-side failures (see embed-protocol.md
// for the full code list). A host that ignores `error`
// silently loses these — your `init` could be rejected
// because of an invalid `context` and the iframe would
// just sit there empty with no console signal in the
// HOST page (the diagnostic only surfaces in the IFRAME's
// devtools, which a screen-share viewer might not even
// have open). Always wire `error`, even if you only
// `console.error` it during development.
//
// Same own-property + per-kind narrowing discipline as
// the other branches: an attacker who passes the
// envelope guard could otherwise satisfy the typeof
// check from inherited prototype keys.
if (data.kind === "error") {
const payload = data.payload;
if (
!payload ||
typeof payload !== "object" ||
Array.isArray(payload) ||
!Object.hasOwn(payload, "code") ||
!Object.hasOwn(payload, "message") ||
typeof payload.code !== "string" ||
typeof payload.message !== "string"
) {
return;
}
yourErrorReporter?.(payload.code, payload.message);
console.error(`[form-builder] ${payload.code}: ${payload.message}`);
}
});
// 5. Assign src last so the listener is wired before the iframe boots.
iframe.src = iframeSrc.toString();
// Strip the host-owned `preview.context` block from a JSONC
// buffer before persistence. The form-builder preserves
// comments and trailing commas verbatim (the buffer is JSONC,
// not strict JSON), so we use `jsonc-parser`'s `modify` — the
// same primitive the form-builder uses internally — instead of
// `JSON.parse` + `JSON.stringify` (which would discard
// comments and re-format the whole buffer).
//
// Returns a tagged result `{ ok, text }`:
//
// - `ok: true, text: <stripped>` when the buffer parsed AND
// the strip ran successfully. The caller should persist
// `text`. This includes the case where `preview.context`
// was already absent (`text` equals the input unchanged).
//
// - `ok: false, text: <verbatim>` when the buffer is
// unparseable JSONC, OR when `modify` / `applyEdits` threw.
// The verbatim text still contains the host-owned
// `preview.context` block (if any), so the CALLER must
// refuse to persist it — see the persistence branch above.
// We return the verbatim text instead of just `null` so
// callers that want to log / display it have it on hand
// without re-reading from the payload.
//
// Two failure modes are covered:
//
// 1. **Buffer not parseable.** Mid-edit a host can receive a
// `change` event for a buffer that has unbalanced braces,
// a stray comma, or any other transient JSONC error.
// `parseTree` returns `undefined` for unparseable input
// AND `modify` may throw on the same input. We return
// `ok: false` for both so the caller skips the save and
// waits for the next clean change.
//
// 2. **Path absent.** If `preview.context` doesn't exist in
// the buffer (the author hasn't opened a preview pane, or
// they manually deleted the block), the strip is a no-op
// and we return `ok: true` with the original text.
function stripHostContext(jsoncText) {
let tree;
const parseErrors = [];
try {
// Pass an `errors` array — `parseTree` is permissive
// and returns a partial tree even for buffers with
// syntax errors (a stray comma, missing brace, …),
// so a tree-truthy check alone would false-pass on
// mid-edit malformed JSONC. We need
// `parseErrors.length === 0` to be sure the persisted
// bytes round-trip cleanly.
//
// `allowTrailingComma: true` matches the form-builder's
// own JSONC parser settings (see
// `editor-state.service.ts`'s `parseFormPackage`). Without
// it, a buffer the form-builder considers valid — for
// instance a uiSchema element list with a deliberately
// trailing comma so the author can re-order entries —
// would parse as an error here and the strip would
// refuse to run, even though the buffer is fine.
tree = parseTree(jsoncText, parseErrors, { allowTrailingComma: true });
} catch (parseError) {
console.warn(
"[form-builder] stripHostContext could not parse buffer; skipping save",
parseError,
);
return { ok: false, text: jsoncText };
}
if (tree === undefined || parseErrors.length > 0) {
console.warn(
"[form-builder] stripHostContext encountered JSONC parse errors; skipping save",
{ errorCount: parseErrors.length },
);
return { ok: false, text: jsoncText };
}
const node = findNodeAtLocation(tree, ["preview", "context"]);
if (node === undefined) return { ok: true, text: jsoncText };
let edits;
try {
edits = modify(jsoncText, ["preview", "context"], undefined, {
formattingOptions: { tabSize: 2, insertSpaces: true },
});
} catch (modifyError) {
console.warn(
"[form-builder] stripHostContext could not modify buffer; skipping save",
modifyError,
);
return { ok: false, text: jsoncText };
}
if (edits.length === 0) return { ok: true, text: jsoncText };
try {
return { ok: true, text: applyEdits(jsoncText, edits) };
} catch (applyError) {
console.warn(
"[form-builder] stripHostContext could not apply edits; skipping save",
applyError,
);
return { ok: false, text: jsoncText };
}
}
</script>
Hosting¶
The form-builder SPA is a static Angular bundle. Host it however you
host any static SPA — S3 + CloudFront, GitHub Pages, your own nginx,
Floh's own server. Once the bundle is deployed, the integration
contract is just the URL and the postMessage protocol.
For local dev:
pnpm --filter @floh/form-builder-app dev
# → https://localhost:7080/?embed=1&hostOrigin=https%3A%2F%2Flocalhost%3A7072
# (or, with the explicit HTTP opt-out:)
pnpm --filter @floh/form-builder-app dev:http
# → http://localhost:7080/?embed=1&hostOrigin=http%3A%2F%2Flocalhost%3A7072
Content-Security-Policy¶
The host page MUST allow the iframe's origin in its frame-src
directive. A strict baseline:
Inline <script type="module"> and script-src 'self'. The
copy-paste quickstart embeds the bridge JS as an INLINE module
script. A strict script-src 'self' CSP will block that
inline script — 'self' only allows scripts loaded from the
host's own origin via <script src="…">, not anything between
<script>...</script> tags on the page. Three ways to ship
the bridge under a 'self' policy:
-
Move the bridge to a same-origin file (preferred). Save the quickstart's
<script type="module">body to e.g./static/embed-bridge.mjson your host, and reference it with<script type="module" src="/static/embed-bridge.mjs">. The fetch is now same-origin and'self'allows it. -
Allow inline modules via a CSP nonce. Generate a per-request cryptographic nonce, include it in both the
<script type="module" nonce="…">tag AND the CSP header:
Content-Security-Policy:
frame-src https://forms.example.com;
script-src 'self' 'nonce-{random-base64-32}';
The nonce must be unguessable (CSP requires ≥128 bits of entropy) and rotated per response, so this requires a server-rendered host page.
- Allow inline modules via a CSP hash. Compute the
sha256(orsha384/sha512) of the script body and list it inscript-src:
The hash covers ONLY the inline body — its imports are still subject to the rest of the policy (see "jsonc-parser import" below). And every change to the inline body requires regenerating the hash and redeploying the CSP, so in practice this option is only manageable for a frozen integration script.
jsonc-parser import. The quickstart imports jsonc-parser
from https://cdn.jsdelivr.net for copy-paste convenience. A
strict script-src 'self' CSP will block that import. Two ways
out:
- Bundle from npm (preferred for production). Install
jsonc-parseras a dependency of your host app and import it from your bundle. The import then resolves to a same-origin URL covered byscript-src 'self':
// After `npm install jsonc-parser` and a build step:
import { modify, applyEdits } from "jsonc-parser";
- Allow the CDN explicitly with a version-pinned URL. If
your host has no build step, add the CDN host to
script-srcAND use a fully-pinned version in the import URL so the CDN can't silently swap module bodies between page loads:
Content-Security-Policy:
frame-src https://forms.example.com;
script-src 'self' https://cdn.jsdelivr.net;
// Pin the EXACT version, not the major-version range. The
// `@3` form in the quickstart resolves to "latest 3.x" on
// every reload — convenient, but a compromised CDN account
// (or upstream maintainer) could ship a new 3.x with
// arbitrary code. `@3.3.1` (or whatever version you tested
// with) freezes the body.
import { modify, applyEdits } from "https://cdn.jsdelivr.net/npm/jsonc-parser@3.3.1/+esm";
Important caveat on Subresource Integrity (SRI) and CSP
hashes. Both mechanisms target a different layer than what
the import statement actually fetches:
- SRI (integrity="sha384-…" on a <script> tag) only
applies to the script tag's own src attribute. An ES
module loaded via the static import inside that script is
a separate fetch the browser performs without consulting
SRI; CSP script-src 'self' 'sha384-…' likewise covers the
inline body, not what the body imports. Putting an SRI hash
on <script type="module"> does NOT pin the body of the
module its import statements pull in. Pinning the import
body itself requires either a build step (option 1 above —
the bundler inlines the dependency and SRI on the bundled
file then covers everything) or browser-level Import Maps
with integrity,
which is not yet broadly supported.
- CSP script-src 'self' 'sha384-…' allows the inline
module to load but does NOT verify the body of any script
it imports. Once cdn.jsdelivr.net is in script-src, the
fetched module body is implicitly trusted by the policy.
The realistic threat-model story for option 2 is therefore: "I trust the CDN to serve the version I pinned." If you need stronger guarantees, use option 1.
The form-builder SPA itself does not require any specific CSP from
the host — all communication is via postMessage, which CSP does
not gate.
Persistence¶
The form-builder does not persist drafts to localStorage in
embed mode. The host page owns the persistence decision: every
change event from the guest carries the latest serialised
formPackage, and the host can debounce, batch with other workflow
saves, persist on blur, ship over the wire, etc. The guest has no
"save" affordance in embed mode — saving is the host's
responsibility.
change.formPackage is the JSONC editor buffer text, not a
JSON string in the strict sense. JSONC is a superset of JSON: it
accepts // and /* … */ comments and trailing commas, both of
which the form-builder preserves verbatim so authors can leave
inline notes inside the package. Hosts that only need the
structured value (e.g. to derive a workflow input schema) must
parse with jsonc-parser or any JSONC-aware parser — bare
JSON.parse will throw on any author-supplied comment. Hosts that
persist the bytes verbatim (the common case) do not need to parse
at all.
For the same reason there is no protocol-level "saved" ACK from
host to guest. The dirty flag in ChangePayload reflects
guest-side change detection only and stays true until the next
init.
Workflow context¶
Both the initial init and subsequent context:update messages
carry an optional context dictionary. The form-builder's preview
renderer reads this from preview.context and resolves
{{ctx.<path>}} tokens against it.
A typical Floh-style mapping:
{
context: {
submitter: { firstName, lastName, email, displayName },
targetUser: { firstName, lastName, email, displayName },
workflow: { id, name, version },
// …prior step outputs as the workflow author defines them
}
}
The dictionary is replaced wholesale on each context:update
— there is no merge semantic. To add a single key, the host re-sends
the full dictionary with the new key included.
Versioning¶
EMBED_PROTOCOL_VERSION is exported as a literal 1. Bumping it
implies a breaking change to the envelope or to one of the existing
payloads. Adding a new kind to one of the allow-lists is not
breaking — old peers silently drop unknown kinds.
Hosts SHOULD inspect the protocolVersion on the inbound ready
payload and refuse to send any further messages if the value does
not match what the host was coded against.
Security model¶
| Concern | Mitigation |
|---|---|
| Cross-origin message theft | hostOrigin URL parameter pins the origin the guest accepts; guest validates event.origin on every inbound. |
| Replay across iframe loads | Per-render nonce minted by the guest. Reload mints a fresh nonce; stale messages are dropped. |
| Cross-iframe confusion | Guest validates event.source === window.parent. Host SHOULD validate event.source === iframe.contentWindow. |
| Unknown / hostile kinds | Disjoint host↔guest allow-lists; both sides drop unknown / wrong-direction kinds before dispatch. |
| Malformed payloads | Each kind has its own payload narrower; guest emits a typed error on shape mismatch instead of silently failing the op. |
| Host iframe injection | CSP frame-src allow-list pins the iframe origin to your form-builder deployment. |
Troubleshooting¶
"My iframe was working but stopped responding after a reload."
- The guest mints a fresh session nonce on every load. A host that
only stores the FIRST
ready's nonce will reject every subsequent message. Treat eachreadyas a session reset — replace the stored nonce and re-sendinitso the new session boots into the expected state.
"My iframe loads but the host never sees ready."
- Confirm
?embed=1is present in the URL. - Confirm the
hostOriginquery parameter is also present and resolves to the same origin your host page is served from (https:is required except forlocalhost/127.0.0.1/[::1]). Without it the bridge falls intono-originand never postsready. - Confirm the host's
messagelistener is wired before assigningiframe.src. - The iframe should print
[fbapp.embed] dropped inbound messageatconsole.debugfor every message it refuses, with areasonfield that distinguishes the failing gate (source-mismatch,origin-mismatch,envelope-malformed,nonce-mismatch,kind-not-allowed). If you see one of these repeatedly, your host is sending messages that fail the named check; if you see none at all, the host probably isn't posting to the iframe yet (verifyiframe.contentWindow.postMessageis being called andtargetOriginis correct). - The iframe also exposes the bridge's internal state on
console.debugonly — there is intentionally nowindow-level global to call from devtools, since exposing a debug hook would drag the bridge into the production bundle for standalone users. To inspect the state machine, set a breakpoint insideEmbedBridgeService.start()or watch theconsole.debugdrop log described above. Possible non-activestates aredisabled(no?embed=1),no-origin(hostOriginURL param missing/invalid), andno-parent(the SPA is loaded as a top-level tab, not iframed).
"The host posts init but the iframe never updates."
- Verify the host echoes the nonce from
readyexactly. The bridge silently drops mismatched nonces. - Verify the host sends
postMessagewithtargetOriginset to the iframe's actual origin (iframe.src). The host MUST NOT use"*": the browser will deliver an unrestricted-target message to whatever document is currently loaded in the frame at delivery time, so if the iframe has been navigated to or substituted with a different document (an attacker's page, a redirect, a transient about:blank during refresh) the payload — including the session nonce and any context the host was about to apply — leaks to that document. Browsers do NOT block delivery for"*"; pinning the expected origin is the only defence.
The guest cannot detect a wildcard targetOrigin after the fact —
MessageEvent only exposes the sender's origin, never the
address the sender used. The same envelope arrives identically
whether the host sent to "*" or to the iframe origin; the
difference is who else might have received a copy.
"The iframe renders the editor but I'm seeing the standalone header inside it."
- The
?embed=1query parameter was not parsed. URL-encoding bugs in the host are the usual cause; double-check the encoded value arrived intact.