Form-Builder Embed Protocol (v1)¶
The standalone form-builder SPA can be loaded by a host page inside an
<iframe> and driven over postMessage. This page is the canonical
specification of the wire protocol: every envelope shape, every kind,
every security check.
For a step-by-step integration walkthrough, see
Embedding the Form Builder. For a runnable reference
host, see packages/form-builder-app/public/embed-host.html (served
at /embed-host.html by pnpm --filter @floh/form-builder-app dev).
Activation¶
The SPA enters embed mode when loaded with the URL query
parameter ?embed=1. Loading without the flag — even inside an
iframe — keeps standalone behaviour intact (branded chrome,
localStorage persistence). The flag is the single source of truth
so an iframe sandbox alone cannot accidentally suppress chrome.
| Parameter | Required | Purpose |
|---|---|---|
embed |
yes | Must be the literal string 1. Any other value (including true, yes, empty) keeps the SPA in standalone mode. |
hostOrigin |
yes* | URL-encoded origin the host page expects messages from. The SPA refuses to start the bridge when missing (state: no-origin). |
*Strictly speaking the SPA boots either way, but with hostOrigin
unset the bridge state stays no-origin and no postMessage listener
is installed. The chrome is still suppressed (so the editor renders
full-bleed inside the iframe), but the host cannot drive it.
Envelope¶
Every message — in either direction — is wrapped in a versioned envelope:
interface EmbedEnvelope<K extends string, P> {
v: 1; // EMBED_PROTOCOL_VERSION
nonce: string; // per-render session nonce
kind: K; // see allow-lists below
id?: string; // reserved for future request/response correlation
payload: P;
}
The receiver MUST drop any message that:
- has
event.sourceother than the expected window (parent for the guest, the iframe'scontentWindowfor the host); - has
event.origin !== expectedOrigin; - fails the envelope shape check (
isEmbedEnvelopeinpackages/form-builder-app/src/app/embed/protocol.ts); - has a missing or wrong
nonce; - has a
kindnot in the allow-list for the receiving direction (sending a guest→host kind to the guest is a protocol violation).
Drops are silent. Hosts can subscribe to console.debug for
inspection during development.
Nonce handshake¶
The nonce is minted by the guest (the form-builder iframe) on load and echoed by the host on every subsequent send. The sequence is:
- Guest loads, calls
EmbedBridgeService.start(). - Guest mints a nonce (
crypto.randomUUID()in production) and postsreadyto itswindow.parent. The nonce is in the envelope and in thereadypayload (so the host can read it before invoking the envelope validator). - Host stores the nonce; sends
init(and every later host→guest message) with that nonce in the envelope. - Either side drops any inbound message whose nonce does not match the active session.
Reloading the iframe (designer template edit, host re-mount, …) mints a fresh nonce, so messages buffered by either side from the prior render are silently dropped instead of being applied to the current session.
For this reason, hosts MUST treat every inbound ready as a
session reset — replace the stored nonce with the new one and
re-send init so the new render boots into the expected state.
Pinning only the very first ready's nonce permanently breaks the
bridge after the first reload because every subsequent host→guest
send fails the nonce check on the new session.
Direction allow-lists¶
host → guest¶
| Kind | Payload type | When sent |
|---|---|---|
init |
InitPayload |
Once per session, after receiving ready. Replaces the buffer with the host's bytes. May also carry initial host-supplied hints. |
context:update |
ContextUpdatePayload |
Whenever the host's view of preview.context changes (workflow vars edited, etc.). |
host:hints:update |
HostHintsUpdatePayload |
Whenever the host's declared workflow variables or context tokens change. Replaces both lists wholesale; an empty array clears a list. Triggers a refreshed change so the host's view of validationIssues reflects whether host tokens now resolve a previously-broken Display.contextScope. |
guest → host¶
| Kind | Payload type | When sent |
|---|---|---|
ready |
ReadyPayload |
Once on guest load, before the host has supplied initial state. |
change |
ChangePayload |
On every parsed-and-validated edit of the form package. |
error |
ErrorPayload |
Internal guest failure (malformed inbound payload, applied edit threw, …). |
Both lists are disjoint. Receiving a guest→host kind on the inbound side at the guest is a protocol violation and the bridge silently drops it.
Payload shapes¶
// guest → host
interface ReadyPayload {
/** Nonce the host should echo on every host→guest message. */
nonce: string;
/** Always equal to EMBED_PROTOCOL_VERSION (currently 1). */
protocolVersion: 1;
}
interface ChangePayload {
/**
* Current Monaco buffer contents — JSONC editor text, NOT a parsed
* object. 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 notes for themselves
* inside the package. Hosts that only need the structured value
* MUST parse with `jsonc-parser` (or any JSONC-aware parser),
* not bare `JSON.parse`. Hosts that persist the bytes verbatim
* (the common case) do not need to parse at all.
*/
formPackage: string;
/** True iff `validateUiSchema` passes against the parsed package. */
valid: boolean;
/**
* Empty when `valid: true` OR when the JSON itself didn't parse —
* a syntax-error buffer has nothing schema-shaped to validate
* against. Every other failure mode (parsed but missing
* `schema`/`uiSchema`, validateUiSchema threw, structural rule
* failures) emits at least one issue describing the cause.
*/
validationIssues: readonly ValidationIssue[];
/**
* True from the moment the buffer first diverges from the most
* recently `init`-loaded text. Stays true until the next `init`;
* not reset by host persistence (the guest has no signal that the
* host accepted the bytes). Host-applied `context:update` writes
* are NOT user edits and do NOT flip this flag.
*/
dirty: boolean;
}
interface ErrorPayload {
code: string; // opaque code (e.g. "init-malformed")
message: string; // human-readable English
}
// host → guest
interface InitPayload {
/**
* JSONC editor buffer text (NOT strict JSON). Fed directly into
* the Monaco buffer, so any `// line` or `/* block */` comments
* and trailing commas survive verbatim. Hosts persisting the
* `change.formPackage` text from a prior session can replay
* those exact bytes here — the comments and trailing commas
* the author left in are preserved end-to-end. Hosts that
* synthesise an init from a parsed object should serialise via
* `JSON.stringify` (no comments to preserve), which is a valid
* JSONC subset and accepted as-is.
*/
formPackage: string;
/** Optional initial preview.context dictionary. Must be JSON-serialisable. */
context?: Record<string, unknown>;
/**
* Optional initial workflow-variable hints. Surfaced in the form
* builder's Palette ("Workflow variables" section), the
* OutputVariable input's autocomplete `<datalist>`, and the
* "Use variable" chip strip. Empty / omitted = standalone form
* builder UI (no host-supplied variables to hint).
*/
workflowVariables?: ReadonlyArray<WorkflowVariableHint>;
/**
* Optional initial context-token hints. Surfaced in the
* `Display.contextScope` picker as `<optgroup>`s alongside the
* author's `preview.context` entries. Empty / omitted = picker
* shows only the author's preview.context.
*/
contextTokens?: ReadonlyArray<ContextTokenHint>;
}
interface ContextUpdatePayload {
/** Replaces preview.context wholesale via a surgical Monaco edit. */
context: Record<string, unknown>;
}
/**
* Replaces both hint lists wholesale. The form builder snapshots
* the most recent payload — older sends are not merged. Hosts that
* want to clear a list send an empty array; omitting a list and
* sending only the other is NOT supported (both fields are
* required) so the host's view of "what hints does the guest
* currently see?" stays unambiguous.
*/
interface HostHintsUpdatePayload {
workflowVariables: ReadonlyArray<WorkflowVariableHint>;
contextTokens: ReadonlyArray<ContextTokenHint>;
}
/**
* Hint shape for a single workflow variable the host expects the
* form to populate at run time. Mirrors the design-time-relevant
* subset of Floh's `VariableDefinition` — runtime fields like
* persisted defaults are intentionally omitted.
*/
interface WorkflowVariableHint {
/** Identifier per `^[A-Za-z_$][A-Za-z0-9_$]*$`, max 200 chars. */
name: string;
/** Coarse type tag — one of the `VariableHintType` literals
* declared below (e.g. "string", "number", "boolean", "json"). */
type: VariableHintType;
/** True iff the workflow requires this variable at run start. */
required: boolean;
/** Optional human-readable description, max 4096 chars. */
description?: string;
}
/**
* Hint shape for a single host-supplied context token the form may
* bind a `Display.contextScope` to. The picker maps each `path`
* segment to a JSON-Pointer `#/context/<seg>/<seg>` so the bound
* scope round-trips through the existing JSON Schema unchanged.
*
* Wire-boundary validation rules (parser is **fail-closed at the
* array level** — any single bad element rejects the whole
* `host:hints:update` / `init` payload):
*
* - **Identifier shape**: every dot-segment of `path` must match
* `^[A-Za-z_$][A-Za-z0-9_$]*$` (no empty segments, no hyphens,
* no leading digits).
* - **Reserved segments**: `path` MUST NOT contain `__proto__`,
* `prototype`, or `constructor` anywhere. Defends against
* prototype-pollution sinks in downstream walkers (see
* `.cursor/rules/form-builder/gotchas/2026-04-30-host-token-prototype-pollution-defense.mdc`).
* - **Source/path consistency**: the FIRST segment of `path` must
* exactly equal the `source` field. A
* `{ source: "submitter", path: "targetUser.email" }` token
* would let the picker bucket the binding under `submitter`
* while the runtime resolved against `targetUser` (silent
* fallback at run time), so the parser rejects the mismatch
* instead.
* - **Path length**: `path.length <= 390` (the schema's full
* `Display.contextScope.maxLength` is 400; 390 leaves room
* for the `#/context/` prefix the picker prepends).
* - **String length cap**: `path` and `description` each
* `<= 4096` chars (most strings on the wire share this cap).
* - **Source enum**: `source` must be one of the closed literals
* below — an unknown source fails the parse and the entire
* payload is rejected. Adding a new source is a coordinated
* protocol revision (parser + picker grouping + runtime
* context generator must all learn the new tag).
*/
interface ContextTokenHint {
/** Dot-joined access path (e.g. "submitter.id"). See validation
* rules in the JSDoc above — identifier-shape, reserved-segment,
* source/path-consistency, and 390-char length checks all
* enforced at the wire boundary. */
path: string;
/** Coarse type tag for the token's value. */
type: VariableHintType;
/** Origin source — drives the picker's `<optgroup>` grouping.
* Closed literal union; the FIRST segment of `path` must
* match this value (see source/path consistency in the JSDoc
* above). */
source: "submitter" | "targetUser" | "workflow";
/** Optional human-readable description, max 4096 chars. */
description?: string;
}
/**
* Coarse type tag, mirrored verbatim from `@floh/shared`'s
* `VariableType`. Kept as a string-literal union (not a type
* import) at the iframe boundary so the standalone form builder
* has no host-package dependency. The wire-boundary parser is
* closed over this literal union — an unknown tag fails the parse
* and the entire `host:hints:update` / `init` payload is rejected,
* matching the parser's behaviour for `ContextTokenHint.source`.
* Adding a new tag is a coordinated protocol revision (parser +
* picker + designer surfaces must all learn it together) so older
* form-builder bundles fail loudly rather than silently rendering
* an opaque variant the rest of the UI can't reason about.
*/
type VariableHintType =
| "string"
| "number"
| "boolean"
| "date"
| "json"
| "user"
| "role"
| "group"
| "user_or_group";
Bridge state¶
The guest's EmbedBridgeService.getState() returns one of:
| State | Meaning |
|---|---|
disabled |
?embed=1 was not on the URL. Bridge is inert; standalone mode. |
no-parent |
?embed=1 was set but window.parent === window (standalone tab). No one to talk to; bridge does nothing. |
no-origin |
?embed=1 set but hostOrigin query parameter is missing or empty. The bridge refuses to install the listener — defensive default. |
version-mismatch |
Reserved for future envelope-version checks against a peer. |
active |
Listener installed, ready posted to parent, change events flowing on every edit. |
A host can detect any non-active state by simply not receiving
ready within a reasonable timeout (no protocol-level negative ACK
exists today — the only "I'm here" signal is ready itself).
Security checks at a glance¶
| Check | Enforced by | Failure mode |
|---|---|---|
event.source === expected window |
Both sides | Silent drop |
event.origin === expected origin |
Both sides | Silent drop |
isEmbedEnvelope(data) shape valid |
Both sides | Silent drop |
Envelope v === 1 |
Both sides | Silent drop |
Envelope nonce === session nonce |
Both sides | Silent drop |
kind in direction allow-list |
Both sides | Silent drop |
| Payload shape narrows | Each kind | Guest emits error; host SHOULD ignore |
The host SHOULD additionally pin the iframe with a CSP frame-src
allow-list that only includes the form-builder origin (see
Embedding the Form Builder § CSP).
Protocol 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 and does not bump the version — old hosts will simply
ignore the new kind (silent drop) and old guests will silently drop
host→guest sends of unknown kinds.
The reserved id field on the envelope is the forward-compat hook
for adding request/response round-trips (e.g. request:current →
current:state) without an envelope version bump.