Webhook destination controls for contact forms #27

Merged
jknapp merged 3 commits from feat/form-webhook-delivery into main 2026-08-11 00:42:03 +00:00
Owner

Adds a webhook destination option to the site-builder contact form: destination selector, URL field, auth-mode selector, and a write-only secret field that POSTs to the panel and persists only the returned id. The raw secret never reaches a Craft prop.

Legacy markers stay byte-identical, proven by differential execution against the base across 30,240 cases. All four new marker attributes go through the existing escape helper, which is what keeps whole classes of publish-time strip failure unreachable.

Pairs with the whp-side branch of the same name. Ship order matters: the panel parser must land before or with the rebuilt bundle, or the feature ships inert.

Adds a webhook destination option to the site-builder contact form: destination selector, URL field, auth-mode selector, and a write-only secret field that POSTs to the panel and persists only the returned id. The raw secret never reaches a Craft prop. Legacy markers stay byte-identical, proven by differential execution against the base across 30,240 cases. All four new marker attributes go through the existing escape helper, which is what keeps whole classes of publish-time strip failure unreachable. Pairs with the whp-side branch of the same name. Ship order matters: the panel parser must land before or with the rebuilt bundle, or the feature ships inert.
jknapp added 3 commits 2026-08-11 00:41:48 +00:00
Custom HTML block renders arbitrary markup on the published site.
Scripts and event handlers are stripped, but forms, iframes, and
images survive and can send data elsewhere. Add a plain, always-shown
advisory (no content detection) alongside the existing wrapper-styling
note.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ContactForm gains four craft props (destinationType, webhookUrl,
webhookSecretId, webhookAuthMode), all present in craft.props defaults so
FormStylePanel's `nodeProps.X !== undefined` gates actually render their
controls. The controls live in FormStylePanel (RightPanel renders only
GuidedStyles, so related.settings would be dead UI).

relayFormWiring widens the marker to optionally carry type/url/secret/
authmode BETWEEN `id` and `recipient`, which is where FormRelayRewrite.php's
parser looks. A marker with no type is byte-identical to what shipped before
-- pinned by a test that diffs an explicit-email form against one with no
destination props at all, since every already-published site depends on that
shape continuing to provision an email endpoint.

Every optional attribute value goes through one escaping site (markerAttr ->
escapeAttr); type and authmode are additionally allowlisted, so a case-drifted
"Bearer" reaches the relay as the exact literal it compares against instead of
being silently downgraded to unsigned.

The raw shared secret is never a prop: it is held in WebhookSecretField's
local state, POSTed to /api/form-webhook-secret.php on blur, and only the
returned opaque id is persisted. The field is write-only (set / replace /
remove, never view) because the endpoint has no read route, and the endpoint's
429 cap message is surfaced verbatim so a customer can act on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I-1: WebhookSecretField now mounts with key={selectedId}. GuidedStyles renders
FormStylePanel with no key, so a selection change re-rendered rather than
remounted it and React kept `draft`/`status`. Since a failed store deliberately
retains the draft, clicking a second contact form showed node A's raw secret in
node B's field, and the next blur assigned the returned secret_id to the wrong
form (burning a slot against the per-site cap). The status banner leaked the
same way. Both pinned by the reviewer's repro sequence.

I-2: the button is "Clear", not "Remove", and says so -- nothing deletes a
stored key file, so the 50-per-site cap counts stores-ever. Labelling it Remove
told the customer they had reclaimed a slot right up until the 429 that said
otherwise. Actually deleting the file is Task 8's territory.

M-1: inline warning when the webhook URL is blank or not absolute https. The
publish step does refuse these, but into an error_log the customer never reads.
Warning only -- isHttpsWebhookUrl never edits the value or blocks the publish,
since either would trade a loud server-side refusal for a silently inert form.

M-2: the BYTE-IDENTITY test now compares against a literal marker captured by
executing the emitter at 071f3447, not against another head-revision output. The
self-comparison could only catch a drift affecting one side; a uniform one
passed it. Verified: a uniform `<!-- WHP-FORM` drift now fails this assertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jknapp merged commit a684d51996 into main 2026-08-11 00:42:03 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: cloud-hosting-platform/site-builder#27