feat(forms): captcha is mandatory on every relay form; remove the toggle

relayFormWiring always emits captcha="1" for relay-wired forms (legacy captcha prop ignored,
old projects with captcha:false load fine). CaptchaMock always shown for relay-wired forms;
FormStylePanel toggle replaced by a read-only note.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185ukXgKV8v9BUrQWXSUW4P
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-10-31 11:29:07 -07:00
1 parent 92da689e62
commit bb97e5b396
10 files changed
+62 -59

No files matched your search

@@ -6,7 +6,7 @@ const toHtml = (ContactForm as any).toHtml;
describe('ContactForm.toHtml relay wiring', () => {
test('with recipientEmail: emits marker, placeholder action, honeypot', () => {
const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '');
expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" recipient="a@b.com" thankyou="\/thx"-->/);
expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" captcha="1" recipient="a@b.com" thankyou="\/thx"-->/);
expect(html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
expect(html).toContain('method="POST"');
expect(html).toContain('name="_gotcha"');
@@ -244,7 +244,9 @@ describe('ContactForm.toHtml destination marker', () => {
// if it has to be edited, every already-published site's forms have changed
// shape and that is the thing to stop, not the test.
const FROZEN_LEGACY_MARKER =
'<!--WHP-FORM id="F_3hodg" recipient="a@example.com" thankyou="/thx"-->';
'<!--WHP-FORM id="F_3hodg" captcha="1" recipient="a@example.com" thankyou="/thx"-->';
// (captcha="1" was added when captcha became mandatory on every relay form: that is the one deliberate
// shape change since 071f3447; the fid and everything else is unchanged.)
const legacy = toHtml({ ...defaultProps, recipientEmail: 'a@example.com', thankYouUrl: '/thx' }, '', 'n1');
expect(legacy.html.startsWith(`${FROZEN_LEGACY_MARKER}<form `)).toBe(true);
+5 -6
View File
@@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { CaptchaMock } from './CaptchaMock';
import { relayFormWiring } from '../../utils/form-relay-wiring';
import { relayFormWiring, isRelayWired } from '../../utils/form-relay-wiring';
import { escapeHtml, escapeAttr, slugId, cssValue, sanitizeInputType } from '../../utils/escape';
import { tokenRef } from '../../utils/tokens';
import { textOnSurface } from '../../utils/color';
@@ -55,7 +55,7 @@ interface ContactFormProps {
webhookUrl?: string;
webhookSecretId?: string;
webhookAuthMode?: 'signature' | 'bearer';
/** ALTCHA captcha (relay path). undefined = legacy design: no marker attr, stored flag untouched. */
/** @deprecated Captcha is mandatory on relay forms. Old saved designs may still hold true/false here; it is ignored. */
captcha?: boolean;
animation?: string;
animationDelay?: string;
@@ -76,7 +76,8 @@ export const ContactForm: UserComponent<ContactFormProps> = ({
submitText = 'Send Message',
submitColor = tokenRef('primary'),
formAction = '#',
captcha,
recipientEmail,
destinationType,
successMessage = 'Thank you! We\'ll get back to you soon.',
style = {},
labelColor = tokenRef('text'),
@@ -154,7 +155,7 @@ export const ContactForm: UserComponent<ContactFormProps> = ({
)}
</div>
))}
{captcha === true && <CaptchaMock />}
{isRelayWired(recipientEmail, destinationType) && <CaptchaMock />}
<button
type="submit"
style={{
@@ -202,7 +203,6 @@ ContactForm.craft = {
webhookUrl: '',
webhookSecretId: '',
webhookAuthMode: 'signature',
captcha: true,
animation: '',
animationDelay: '',
hideOnDesktop: false,
@@ -275,7 +275,6 @@ ContactForm.craft = {
secretId: props.webhookSecretId,
authMode: props.webhookAuthMode,
},
props.captcha,
);
// The form-sender relay delivers success via a full-page 303 redirect
@@ -6,7 +6,7 @@ const toHtml = (FormContainer as any).toHtml;
describe('FormContainer.toHtml relay wiring', () => {
test('with recipientEmail: marker + placeholder action + honeypot, forces POST', () => {
const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', method: 'GET' }, '<input name="email">');
expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" recipient="a@b.com" thankyou="\/thx"-->/);
expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" captcha="1" recipient="a@b.com" thankyou="\/thx"-->/);
expect(html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
expect(html).toContain('method="POST"'); // relay forces POST even though method=GET
expect(html).toContain('name="_gotcha"');
+5 -6
View File
@@ -4,14 +4,14 @@ import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from '../layout/Container';
import { cssPropsToString } from '../../utils/style-helpers';
import { CaptchaMock } from './CaptchaMock';
import { relayFormWiring } from '../../utils/form-relay-wiring';
import { relayFormWiring, isRelayWired } from '../../utils/form-relay-wiring';
import { sanitizeFormMethod } from '../../utils/escape';
interface FormContainerProps {
action?: string;
method?: 'GET' | 'POST';
recipientEmail?: string;
/** ALTCHA captcha (relay path). undefined = legacy design: no marker attr, stored flag untouched. */
/** @deprecated Captcha is mandatory on relay forms. Old saved designs may still hold true/false here; it is ignored. */
captcha?: boolean;
thankYouUrl?: string;
style?: CSSProperties;
@@ -27,7 +27,7 @@ export const FormContainer: UserComponent<FormContainerProps> = ({
action = '#',
method = 'POST',
style = {},
captcha,
recipientEmail,
}) => {
const { connectors: { connect, drag } } = useNode();
@@ -50,7 +50,7 @@ export const FormContainer: UserComponent<FormContainerProps> = ({
style={{ display: 'flex', flexDirection: 'column', gap: '16px', padding: '0' }}
tag="div"
/>
{captcha === true && <CaptchaMock style={{ marginTop: 12 }} />}
{isRelayWired(recipientEmail) && <CaptchaMock style={{ marginTop: 12 }} />}
</form>
);
};
@@ -64,7 +64,6 @@ FormContainer.craft = {
method: 'POST',
recipientEmail: '',
thankYouUrl: '',
captcha: true,
style: {
padding: '24px',
backgroundColor: tokenRef('bg'),
@@ -91,7 +90,7 @@ FormContainer.craft = {
padding: '24px',
...props.style,
});
const { useRelay, marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.action, nodeId, undefined, props.captcha);
const { useRelay, marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.action, nodeId);
const method = useRelay ? 'POST' : sanitizeFormMethod(props.method); // relay requires POST
const body = honeypot + childrenHtml; // honeypot as first child
return {
@@ -50,7 +50,7 @@ describe('SubscribeForm.toHtml is functional (not a dead POST)', () => {
test('with recipientEmail: emits the relay marker, placeholder action, and honeypot -- a working submission path', () => {
const { html } = toHtml({ recipientEmail: 'news@example.com', thankYouUrl: '/thanks' }, '', 'node-sub1');
expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" recipient="news@example.com" thankyou="\/thanks"-->/);
expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" captcha="1" recipient="news@example.com" thankyou="\/thanks"-->/);
expect(html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
expect(html).toContain('name="_gotcha"');
const mid = html.match(/id="(F_[0-9a-z]+)"/)![1];
+5 -6
View File
@@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { CaptchaMock } from './CaptchaMock';
import { relayFormWiring } from '../../utils/form-relay-wiring';
import { relayFormWiring, isRelayWired } from '../../utils/form-relay-wiring';
import { escapeHtml, escapeAttr } from '../../utils/escape';
import { tokenRef } from '../../utils/tokens';
import { textOnSurface } from '../../utils/color';
@@ -26,7 +26,7 @@ interface SubscribeFormProps {
* published form then has no working action at all, which is the bug
* this prop exists to fix. */
recipientEmail?: string;
/** ALTCHA captcha (relay path). undefined = legacy design: no marker attr, stored flag untouched. */
/** @deprecated Captcha is mandatory on relay forms. Old saved designs may still hold true/false here; it is ignored. */
captcha?: boolean;
thankYouUrl?: string;
animation?: string;
@@ -43,7 +43,7 @@ export const SubscribeForm: UserComponent<SubscribeFormProps> = ({
buttonColor = tokenRef('primary'),
layout = 'inline',
style = {},
captcha,
recipientEmail,
}) => {
const {
connectors: { connect, drag },
@@ -120,7 +120,7 @@ export const SubscribeForm: UserComponent<SubscribeFormProps> = ({
{buttonText}
</button>
</form>
{captcha === true && <CaptchaMock style={{ margin: '12px auto 0' }} />}
{isRelayWired(recipientEmail) && <CaptchaMock style={{ margin: '12px auto 0' }} />}
</div>
);
};
@@ -138,7 +138,6 @@ SubscribeForm.craft = {
style: { backgroundColor: tokenRef('bg') },
recipientEmail: '',
thankYouUrl: '',
captcha: true,
animation: '',
animationDelay: '',
hideOnDesktop: false,
@@ -208,7 +207,7 @@ SubscribeForm.craft = {
// `formAction`-less relay wiring (fallbackAction undefined -> '#') keeps
// the old no-recipient case visually identical (action="#") while making
// the relay path actually functional once an admin sets an email.
const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, undefined, nodeId, undefined, props.captcha);
const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, undefined, nodeId);
return {
html: `${marker}<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>
@@ -13,24 +13,22 @@ const base = { recipientEmail: 'a@b.com', thankYouUrl: '/thx' };
describe.each(cases)('%s captcha marker attribute', (_n, toHtml, extra) => {
const run = (p: any) => toHtml({ ...extra, ...base, ...p }, '', 'node1').html as string;
test('true emits captcha="1" between id and recipient', () => {
expect(run({ captcha: true })).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" captcha="1" recipient="a@b.com" thankyou="\/thx"-->/);
const MARK = /<!--WHP-FORM id="F_[0-9a-z]+" captcha="1" recipient="a@b.com" thankyou="\/thx"-->/;
test('relay form always emits captcha="1" between id and recipient', () => {
expect(run({ captcha: true })).toMatch(MARK);
});
test('explicit false emits captcha="0"', () => {
expect(run({ captcha: false })).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" captcha="0" recipient=/);
test.each([[false], [undefined], ['yes'], [1], [0], [null]])('mandatory: legacy prop %p still emits captcha="1" (no opt-out)', (v) => {
const html = run({ captcha: v });
expect(html).toMatch(MARK);
expect(html).not.toContain('captcha="0"');
expect(html).toBe(run({ captcha: true }));
});
test('undefined (legacy design) is byte-identical to the legacy marker', () => {
const html = run({});
expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" recipient="a@b.com" thankyou="\/thx"-->/);
expect(html).not.toContain('captcha');
expect(run({ captcha: undefined })).toBe(html);
});
test('non-boolean junk emits nothing', () => {
expect(run({ captcha: 'yes' })).not.toContain('captcha');
expect(run({ captcha: 1 })).not.toContain('captcha');
test('no captcha prop at all (new design) emits captcha="1"', () => {
expect(run({})).toMatch(MARK);
});
test('no relay (no recipient): no marker at all, even with captcha true', () => {
expect(toHtml({ ...extra, captcha: true }, '', 'n').html).not.toContain('WHP-FORM');
expect(toHtml({ ...extra, captcha: false }, '', 'n').html).not.toContain('captcha');
});
test('never emits widget markup (injected at publish)', () => {
expect(run({ captcha: true })).not.toMatch(/altcha-widget|challenge\.php|widget\.php/);
@@ -42,8 +40,18 @@ test('webhook marker keeps captcha attr inside the strict-regex attribute zone',
expect(m).toMatch(/^<!--WHP-FORM id="[^"]+"(?: [a-z]+="[^"]*")* captcha="1" recipient=/);
});
describe('new forms default to captcha on', () => {
test.each([ContactForm, SubscribeForm, FormContainer])('%#', (C: any) => {
expect(C.craft.props.captcha).toBe(true);
describe('captcha is not a configurable prop any more', () => {
test.each([ContactForm, SubscribeForm, FormContainer])('%#: no captcha default in craft.props', (C: any) => {
expect(C.craft.props.captcha).toBeUndefined();
});
test('relayFormWiring ignores the legacy 6th argument', () => {
const on = relayFormWiring('a@b.com', '', undefined, 'n', undefined).marker;
expect(relayFormWiring('a@b.com', '', undefined, 'n', undefined, false).marker).toBe(on);
expect(on).toContain(' captcha="1"');
});
test('a form with an old captcha:false still renders its HTML without error', () => {
for (const [, toHtml, extra] of cases) {
expect(() => toHtml({ ...extra, ...base, captcha: false }, '', 'n1')).not.toThrow();
}
});
});
@@ -369,19 +369,10 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
</p>
</div>
)}
{nodeProps.captcha !== undefined && (
{nodeProps.recipientEmail !== undefined && (
<div style={sectionGap}>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
data-testid="form-captcha"
checked={nodeProps.captcha === true}
onChange={(e) => setProp('captcha', e.target.checked)}
/>
Spam protection (captcha)
</label>
<p style={hintStyle}>
Adds a privacy-friendly ALTCHA check to the published form (served by the contact-form relay; visitors need JavaScript). Only applies when submissions are sent through the relay.
<p style={hintStyle} data-testid="form-captcha-note">
Spam protection (captcha) is always on for forms.
</p>
</div>
)}
+2 -2
View File
@@ -54,7 +54,7 @@ describe('relayFormWiring email destination stays byte-identical to the legacy s
expect(undef.marker).toBe(legacy.marker);
expect(email.marker).toBe(legacy.marker);
expect(email.actionAttr).toBe(legacy.actionAttr);
expect(legacy.marker).toMatch(/^<!--WHP-FORM id="F_[0-9a-z]+" recipient="a@b\.com" thankyou="\/thx"-->$/);
expect(legacy.marker).toMatch(/^<!--WHP-FORM id="F_[0-9a-z]+" captcha="1" recipient="a@b\.com" thankyou="\/thx"-->$/);
});
test('an email form with no recipient is still not a relay at all', () => {
@@ -78,7 +78,7 @@ describe('relayFormWiring webhook marker matches the publish-side parser exactly
test('the optional attributes sit BETWEEN id and recipient, lowercase-named', () => {
const attrs = w.marker.match(PUBLISH_MARKER_RE)![2];
expect(attrs).toBe(' type="webhook" url="https://hooks.example.com/x" secret="whs_7_abc123" authmode="bearer"');
expect(attrs).toBe(' type="webhook" url="https://hooks.example.com/x" secret="whs_7_abc123" authmode="bearer" captcha="1"');
});
test('a webhook with no recipient still emits a relay marker', () => {
+12 -7
View File
@@ -63,6 +63,11 @@ function markerAttr(name: string, value: string): string {
return ` ${name}="${escapeAttr(value)}"`;
}
/** Is a form wired to the relay (recipient set, or webhook destination)? Captcha is always on for these. */
export function isRelayWired(recipientEmail?: string, destinationType?: string): boolean {
return !!recipientEmail || allowlist(destinationType, DESTINATION_TYPES, 'email') === 'webhook';
}
export interface RelayWiring {
/** true when a recipient is set (relay path); false = legacy formAction fallback */
useRelay: boolean;
@@ -82,10 +87,10 @@ export interface RelayWiring {
* the marker/placeholder id deterministically and uniquely --
* see `scopeId` in ./escape. Falls back to a stable hash of the
* recipient/thankYouUrl/fallbackAction when omitted (never random).
* @param captcha ALTCHA captcha intent for the relay (whp: FormRelayRewrite). `true` emits
* `captcha="1"`, EXPLICIT `false` emits `captcha="0"` (user un-ticked: the
* relay must clear a stored flag), anything else (undefined/legacy) emits NO
* attribute, so legacy saved designs stay byte-identical and keep their stored flag.
* @param _legacyCaptcha IGNORED. Captcha is MANDATORY on every relay form (no opt-out): a relay-wired form
* always emits `captcha="1"`, whatever an old saved design holds in its `captcha` prop
* (undefined, true, false or junk). Kept as a parameter only so existing call sites and
* old projects keep compiling/loading; the publish step ignores the attribute value too.
* @param destination optional destination descriptor. Omitted, or `type: 'email'`,
* yields the LEGACY narrow marker byte for byte -- every
* already-published site depends on that shape continuing to
@@ -111,7 +116,7 @@ export function relayFormWiring(
fallbackAction: string | undefined,
nodeId?: string,
destination?: FormDestination,
captcha?: boolean,
_legacyCaptcha?: unknown,
): RelayWiring {
const destType = allowlist(destination?.type, DESTINATION_TYPES, 'email');
const isWebhook = destType === 'webhook';
@@ -141,8 +146,8 @@ export function relayFormWiring(
+ markerAttr('authmode', allowlist(destination?.authMode, AUTH_MODES, 'signature'))
: '';
const captchaAttr = captcha === true ? markerAttr('captcha', '1')
: captcha === false ? markerAttr('captcha', '0') : '';
// Always on: the widget, flag and script are applied together at publish (whp: FormRelayRewrite).
const captchaAttr = markerAttr('captcha', '1');
return {
useRelay: true,