From 3501e5937db29853ec663723fecbd248e360ee47 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 8 Aug 2026 10:39:32 +0100 Subject: [PATCH 01/18] feat: add Cloudflare Turnstile siteverify helper Canonical fail-closed verification against challenges.cloudflare.com using TURNSTILE_SECRET and the request token/IP. --- utils/turnstile.test.js | 90 +++++++++++++++++++++++++++++++++++++++++ utils/turnstile.ts | 79 ++++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 utils/turnstile.test.js create mode 100644 utils/turnstile.ts diff --git a/utils/turnstile.test.js b/utils/turnstile.test.js new file mode 100644 index 000000000..2fc1b5bbc --- /dev/null +++ b/utils/turnstile.test.js @@ -0,0 +1,90 @@ +/** @jest-environment node */ +/* eslint-env jest, node */ + +const { + getClientIp, + getTurnstileToken, + verifyTurnstileToken, + TURNSTILE_TOKEN_FIELD, +} = require('./turnstile.ts') + +describe('turnstile helpers', () => { + const originalSecret = process.env.TURNSTILE_SECRET + + beforeEach(() => { + process.env.TURNSTILE_SECRET = 'test-secret' + }) + + afterAll(() => { + if (originalSecret === undefined) { + delete process.env.TURNSTILE_SECRET + } else { + process.env.TURNSTILE_SECRET = originalSecret + } + }) + + it('reads the token field from the request body', () => { + expect( + getTurnstileToken({ [TURNSTILE_TOKEN_FIELD]: 'token-abc' }) + ).toBe('token-abc') + expect(getTurnstileToken({ [TURNSTILE_TOKEN_FIELD]: '' })).toBeUndefined() + expect(getTurnstileToken({})).toBeUndefined() + }) + + it('prefers the first x-forwarded-for hop', () => { + expect( + getClientIp({ + headers: { 'x-forwarded-for': '1.2.3.4, 5.6.7.8' }, + socket: { remoteAddress: '127.0.0.1' }, + }) + ).toBe('1.2.3.4') + }) + + it('siteverifies successfully when Cloudflare returns success', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }) + + await expect( + verifyTurnstileToken('token', '1.2.3.4', fetchImpl) + ).resolves.toBe(true) + + expect(fetchImpl).toHaveBeenCalledWith( + 'https://challenges.cloudflare.com/turnstile/v0/siteverify', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + ) + const body = fetchImpl.mock.calls[0][1].body + expect(body.get('secret')).toBe('test-secret') + expect(body.get('response')).toBe('token') + expect(body.get('remoteip')).toBe('1.2.3.4') + }) + + it('fails closed when Cloudflare returns success false', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + success: false, + 'error-codes': ['invalid-input-response'], + }), + }) + + await expect(verifyTurnstileToken('token', undefined, fetchImpl)).resolves.toBe( + false + ) + }) + + it('fails closed on network errors and missing secret or token', async () => { + await expect( + verifyTurnstileToken('token', undefined, jest.fn().mockRejectedValue(new Error('net'))) + ).resolves.toBe(false) + + await expect(verifyTurnstileToken(undefined)).resolves.toBe(false) + + delete process.env.TURNSTILE_SECRET + await expect(verifyTurnstileToken('token')).resolves.toBe(false) + }) +}) diff --git a/utils/turnstile.ts b/utils/turnstile.ts new file mode 100644 index 000000000..2fa778a67 --- /dev/null +++ b/utils/turnstile.ts @@ -0,0 +1,79 @@ +import type { NextApiRequest } from 'next' + +export const TURNSTILE_TOKEN_FIELD = 'cf-turnstile-response' + +export const TURNSTILE_FAILURE_MESSAGE = + 'Bot verification failed. Please try again.' + +interface SiteverifyResult { + success?: boolean + 'error-codes'?: string[] +} + +export function getClientIp(req: NextApiRequest): string | undefined { + const forwarded = req.headers['x-forwarded-for'] + if (typeof forwarded === 'string' && forwarded.length > 0) { + return forwarded.split(',')[0]?.trim() || undefined + } + if (Array.isArray(forwarded) && forwarded[0]) { + return forwarded[0].split(',')[0]?.trim() || undefined + } + const realIp = req.headers['x-real-ip'] + if (typeof realIp === 'string' && realIp.length > 0) { + return realIp + } + return req.socket?.remoteAddress || undefined +} + +export function getTurnstileToken(body: unknown): string | undefined { + if (!body || typeof body !== 'object') return undefined + const token = (body as Record)[TURNSTILE_TOKEN_FIELD] + return typeof token === 'string' && token.length > 0 ? token : undefined +} + +/** + * Canonical Cloudflare Turnstile siteverify. + * Fail closed on network errors, non-2xx, bad JSON, or success !== true. + */ +export async function verifyTurnstileToken( + token: string | undefined, + remoteip?: string, + fetchImpl: typeof fetch = fetch +): Promise { + const secret = process.env.TURNSTILE_SECRET + if (!secret || !token) return false + + try { + const params = new URLSearchParams({ + secret, + response: token, + }) + if (remoteip) params.set('remoteip', remoteip) + + const response = await fetchImpl( + 'https://challenges.cloudflare.com/turnstile/v0/siteverify', + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params, + } + ) + if (!response.ok) return false + + const result = (await response.json()) as SiteverifyResult + return result.success === true + } catch { + return false + } +} + +export async function assertTurnstile( + req: NextApiRequest, + fetchImpl: typeof fetch = fetch +): Promise { + return verifyTurnstileToken( + getTurnstileToken(req.body), + getClientIp(req), + fetchImpl + ) +} From 82ff442eaa5f1bff08b10405da068c3e7efa78b8 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 8 Aug 2026 10:39:40 +0100 Subject: [PATCH 02/18] feat: require Turnstile verification on grant apply APIs Gate `/api/github` and `/api/sendgrid` on canonical siteverify before creating issues or sending mail. Strip the token from the email body. --- pages/api/github.ts | 8 ++++++++ pages/api/sendgrid.ts | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/pages/api/github.ts b/pages/api/github.ts index 481124b16..1102229f0 100644 --- a/pages/api/github.ts +++ b/pages/api/github.ts @@ -1,4 +1,8 @@ import { NextApiRequest, NextApiResponse } from 'next/types' +import { + assertTurnstile, + TURNSTILE_FAILURE_MESSAGE, +} from '@/utils/turnstile' const GH_ACCESS_TOKEN = process.env.GH_ACCESS_TOKEN const GH_ORG = process.env.GH_ORG @@ -12,6 +16,10 @@ export default async function handler( res: NextApiResponse ) { if (req.method === 'POST') { + if (!(await assertTurnstile(req))) { + return res.status(403).json({ message: TURNSTILE_FAILURE_MESSAGE }) + } + if (!GH_ACCESS_TOKEN || !GH_ORG || !GH_APP_REPO) { throw new Error('Env misconfigured') } diff --git a/pages/api/sendgrid.ts b/pages/api/sendgrid.ts index fb38bc990..f99b74b86 100644 --- a/pages/api/sendgrid.ts +++ b/pages/api/sendgrid.ts @@ -1,6 +1,11 @@ import { NextApiRequest, NextApiResponse } from 'next/types' import sgMail from '@sendgrid/mail' import { marked } from 'marked' +import { + assertTurnstile, + TURNSTILE_FAILURE_MESSAGE, + TURNSTILE_TOKEN_FIELD, +} from '@/utils/turnstile' const SENDGRID_API_KEY = process.env.SENDGRID_API_KEY const TO_ADDRESS = process.env.SENDGRID_RECIPIENT @@ -263,11 +268,16 @@ export default async function handler( return res.status(405).end('Method Not Allowed') } + if (!(await assertTurnstile(req))) { + return res.status(403).json({ message: TURNSTILE_FAILURE_MESSAGE }) + } + if (!SENDGRID_API_KEY || !TO_ADDRESS || !FROM_ADDRESS) { throw new Error('Env misconfigured') } const body = Object.entries(req.body) + .filter(([key]) => key !== TURNSTILE_TOKEN_FIELD) .map( ([key, value]) => `

${escapeHtml(key)}

${escapeHtml(value)}

` ) From 2fcd911bb084de98090aae1b935c3d9421db62ba Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 8 Aug 2026 10:40:35 +0100 Subject: [PATCH 03/18] feat: embed Turnstile on grant application submit Show the challenge on the final step, require a token before submit, and refresh the token between GitHub and SendGrid siteverify calls. --- .../MultiStepApplicationForm.tsx | 23 ++- .../grant-application/TurnstileWidget.tsx | 137 ++++++++++++++++++ utils/application-submission.test.js | 63 +++++--- utils/application-submission.ts | 47 +++++- 4 files changed, 246 insertions(+), 24 deletions(-) create mode 100644 components/grant-application/TurnstileWidget.tsx diff --git a/components/grant-application/MultiStepApplicationForm.tsx b/components/grant-application/MultiStepApplicationForm.tsx index fdd5466e8..6765262d4 100644 --- a/components/grant-application/MultiStepApplicationForm.tsx +++ b/components/grant-application/MultiStepApplicationForm.tsx @@ -9,6 +9,7 @@ import { } from '../../utils/application-submission' import StepIndicator from './StepIndicator' import StepNavigation from './StepNavigation' +import TurnstileWidget, { TurnstileWidgetHandle } from './TurnstileWidget' import { FormValues, StepProps } from './types' export interface StepConfig { @@ -38,7 +39,9 @@ export default function MultiStepApplicationForm({ const [loading, setLoading] = useState(false) const [failureReason, setFailureReason] = useState() const [failedAttempts, setFailedAttempts] = useState(0) + const [turnstileReady, setTurnstileReady] = useState(false) const formRef = useRef(null) + const turnstileRef = useRef(null) const router = useRouter() const { @@ -84,7 +87,10 @@ export default function MultiStepApplicationForm({ } } - const submitDisabled = !!submitRequiresChecked?.some((name) => !watch(name)) + const isLastStep = currentStep === steps.length - 1 + const submitDisabled = + !!submitRequiresChecked?.some((name) => !watch(name)) || + (isLastStep && !turnstileReady) // eslint-disable-next-line @typescript-eslint/no-explicit-any const onSubmit = async (data: any) => { @@ -94,7 +100,11 @@ export default function MultiStepApplicationForm({ setFailureReason(undefined) try { - await submitApplication(data) + const turnstile = turnstileRef.current + if (!turnstile) { + throw new Error('Please complete the bot verification challenge.') + } + await submitApplication(data, undefined, turnstile) await router.push('/submitted') } catch (e) { const nextFailures = failedAttempts + 1 @@ -105,6 +115,8 @@ export default function MultiStepApplicationForm({ ? `${baseMessage} ${SUBMISSION_CONTACT}` : baseMessage ) + setTurnstileReady(false) + void turnstileRef.current?.reset() } finally { setLoading(false) } @@ -132,6 +144,13 @@ export default function MultiStepApplicationForm({ {steps[currentStep].render(stepProps)} + {isLastStep && ( + setTurnstileReady(!!token)} + /> + )} + void + 'expired-callback'?: () => void + 'error-callback'?: () => void + } + ) => string + reset: (widgetId?: string) => void + remove: (widgetId?: string) => void + } + } +} + +export interface TurnstileWidgetHandle { + getToken: () => string | undefined + waitForToken: () => Promise + reset: () => Promise +} + +interface TurnstileWidgetProps { + onTokenChange?: (token: string | undefined) => void +} + +const TurnstileWidget = forwardRef( + function TurnstileWidget({ onTokenChange }, ref) { + const containerRef = useRef(null) + const widgetIdRef = useRef() + const tokenRef = useRef() + const waitersRef = useRef void>>([]) + const [scriptReady, setScriptReady] = useState(false) + + const setToken = useCallback( + (token: string | undefined) => { + tokenRef.current = token + onTokenChange?.(token) + if (token) { + const waiters = waitersRef.current + waitersRef.current = [] + waiters.forEach((resolve) => resolve(token)) + } + }, + [onTokenChange] + ) + + const waitForToken = useCallback(() => { + if (tokenRef.current) return Promise.resolve(tokenRef.current) + return new Promise((resolve) => { + waitersRef.current.push(resolve) + }) + }, []) + + const reset = useCallback(() => { + setToken(undefined) + if (widgetIdRef.current && window.turnstile) { + window.turnstile.reset(widgetIdRef.current) + } + return waitForToken() + }, [setToken, waitForToken]) + + useImperativeHandle( + ref, + () => ({ + getToken: () => tokenRef.current, + waitForToken, + reset, + }), + [reset, waitForToken] + ) + + useEffect(() => { + if (!scriptReady || !SITE_KEY || !containerRef.current || !window.turnstile) { + return + } + if (widgetIdRef.current) return + + widgetIdRef.current = window.turnstile.render(containerRef.current, { + sitekey: SITE_KEY, + action: 'turnstile-spin-v2', + callback: (token) => setToken(token), + 'expired-callback': () => setToken(undefined), + 'error-callback': () => setToken(undefined), + }) + + return () => { + if (widgetIdRef.current && window.turnstile) { + window.turnstile.remove(widgetIdRef.current) + widgetIdRef.current = undefined + } + } + }, [scriptReady, setToken]) + + if (!SITE_KEY) { + return ( +

+ Turnstile is not configured (`NEXT_PUBLIC_TURNSTILE_SITE_KEY`). +

+ ) + } + + return ( + <> + ' ) + expect(sgMail.send.mock.calls[0][0].html).not.toContain( + 'cf-turnstile-response' + ) + }) + + it('rejects requests that fail Turnstile verification', async () => { + assertTurnstile.mockResolvedValue(false) + const response = responseMock() + + await handler({ method: 'POST', body: validApplication }, response) + + expect(response.statusCode).toBe(403) + expect(response.payload.message).not.toBe('success') + expect(sgMail.send).not.toHaveBeenCalled() }) }) + From 48b52ae6e673f22a3b20215cdf1b70843d39e650 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 8 Aug 2026 10:42:58 +0100 Subject: [PATCH 05/18] style: fix prettier lint failures for Turnstile changes --- .../grant-application/TurnstileWidget.tsx | 7 ++++++- pages/api/github.ts | 5 +---- utils/application-submission.test.js | 7 ++++--- utils/getBlogLayout.ts | 4 +++- utils/turnstile.test.js | 18 +++++++++++------- 5 files changed, 25 insertions(+), 16 deletions(-) diff --git a/components/grant-application/TurnstileWidget.tsx b/components/grant-application/TurnstileWidget.tsx index 55184cf93..9577f5dca 100644 --- a/components/grant-application/TurnstileWidget.tsx +++ b/components/grant-application/TurnstileWidget.tsx @@ -87,7 +87,12 @@ const TurnstileWidget = forwardRef( ) useEffect(() => { - if (!scriptReady || !SITE_KEY || !containerRef.current || !window.turnstile) { + if ( + !scriptReady || + !SITE_KEY || + !containerRef.current || + !window.turnstile + ) { return } if (widgetIdRef.current) return diff --git a/pages/api/github.ts b/pages/api/github.ts index 1102229f0..36649a26d 100644 --- a/pages/api/github.ts +++ b/pages/api/github.ts @@ -1,8 +1,5 @@ import { NextApiRequest, NextApiResponse } from 'next/types' -import { - assertTurnstile, - TURNSTILE_FAILURE_MESSAGE, -} from '@/utils/turnstile' +import { assertTurnstile, TURNSTILE_FAILURE_MESSAGE } from '@/utils/turnstile' const GH_ACCESS_TOKEN = process.env.GH_ACCESS_TOKEN const GH_ORG = process.env.GH_ORG diff --git a/utils/application-submission.test.js b/utils/application-submission.test.js index f69c21b7b..215094703 100644 --- a/utils/application-submission.test.js +++ b/utils/application-submission.test.js @@ -9,7 +9,9 @@ describe('submitApplication', () => { function turnstileMock(tokens) { let index = 0 return { - waitForToken: jest.fn(async () => tokens[Math.min(index, tokens.length - 1)]), + waitForToken: jest.fn( + async () => tokens[Math.min(index, tokens.length - 1)] + ), reset: jest.fn(async () => { index += 1 return tokens[Math.min(index, tokens.length - 1)] @@ -70,8 +72,7 @@ describe('submitApplication', () => { it('forces a retry when GitHub returns a non-success response', async () => { const postJSON = jest.fn(async (url) => ({ - message: - url === '/api/github' ? 'Application storage failed' : 'success', + message: url === '/api/github' ? 'Application storage failed' : 'success', })) const turnstile = turnstileMock(['token-1', 'token-2']) diff --git a/utils/getBlogLayout.ts b/utils/getBlogLayout.ts index c4eaba050..102bcacc8 100644 --- a/utils/getBlogLayout.ts +++ b/utils/getBlogLayout.ts @@ -17,6 +17,8 @@ export function getBlogLayout(post: Pick): string { return DEFAULT_LAYOUT } -export function isSpotlightLayout(post: Pick): boolean { +export function isSpotlightLayout( + post: Pick +): boolean { return getBlogLayout(post) === SPOTLIGHT_LAYOUT } diff --git a/utils/turnstile.test.js b/utils/turnstile.test.js index 2fc1b5bbc..063a4fd66 100644 --- a/utils/turnstile.test.js +++ b/utils/turnstile.test.js @@ -24,9 +24,9 @@ describe('turnstile helpers', () => { }) it('reads the token field from the request body', () => { - expect( - getTurnstileToken({ [TURNSTILE_TOKEN_FIELD]: 'token-abc' }) - ).toBe('token-abc') + expect(getTurnstileToken({ [TURNSTILE_TOKEN_FIELD]: 'token-abc' })).toBe( + 'token-abc' + ) expect(getTurnstileToken({ [TURNSTILE_TOKEN_FIELD]: '' })).toBeUndefined() expect(getTurnstileToken({})).toBeUndefined() }) @@ -72,14 +72,18 @@ describe('turnstile helpers', () => { }), }) - await expect(verifyTurnstileToken('token', undefined, fetchImpl)).resolves.toBe( - false - ) + await expect( + verifyTurnstileToken('token', undefined, fetchImpl) + ).resolves.toBe(false) }) it('fails closed on network errors and missing secret or token', async () => { await expect( - verifyTurnstileToken('token', undefined, jest.fn().mockRejectedValue(new Error('net'))) + verifyTurnstileToken( + 'token', + undefined, + jest.fn().mockRejectedValue(new Error('net')) + ) ).resolves.toBe(false) await expect(verifyTurnstileToken(undefined)).resolves.toBe(false) From 2daf5901a8452da54d6eb462485922e773afc6e5 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 8 Aug 2026 10:53:04 +0100 Subject: [PATCH 06/18] fix: clear stale Turnstile readiness and split reset APIs Reset no longer enqueues waiters on fire-and-forget paths, and leaving the final step clears turnstileReady so submit stays gated. --- .../MultiStepApplicationForm.tsx | 13 ++++++++-- .../grant-application/TurnstileWidget.tsx | 15 +++++++++--- utils/application-submission.test.js | 24 +++++++++++-------- utils/application-submission.ts | 9 ++++--- 4 files changed, 41 insertions(+), 20 deletions(-) diff --git a/components/grant-application/MultiStepApplicationForm.tsx b/components/grant-application/MultiStepApplicationForm.tsx index 6765262d4..289c0c88e 100644 --- a/components/grant-application/MultiStepApplicationForm.tsx +++ b/components/grant-application/MultiStepApplicationForm.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useRef, useState } from 'react' +import { ReactNode, useEffect, useRef, useState } from 'react' import { useRouter } from 'next/router' import { DefaultValues, useForm } from 'react-hook-form' import { @@ -88,6 +88,15 @@ export default function MultiStepApplicationForm({ } const isLastStep = currentStep === steps.length - 1 + + // Widget unmounts when leaving the last step; clear readiness so submit + // cannot stay enabled on a stale token when the user returns. + useEffect(() => { + if (!isLastStep) { + setTurnstileReady(false) + } + }, [isLastStep]) + const submitDisabled = !!submitRequiresChecked?.some((name) => !watch(name)) || (isLastStep && !turnstileReady) @@ -116,7 +125,7 @@ export default function MultiStepApplicationForm({ : baseMessage ) setTurnstileReady(false) - void turnstileRef.current?.reset() + turnstileRef.current?.reset() } finally { setLoading(false) } diff --git a/components/grant-application/TurnstileWidget.tsx b/components/grant-application/TurnstileWidget.tsx index 9577f5dca..726a64adc 100644 --- a/components/grant-application/TurnstileWidget.tsx +++ b/components/grant-application/TurnstileWidget.tsx @@ -33,7 +33,10 @@ declare global { export interface TurnstileWidgetHandle { getToken: () => string | undefined waitForToken: () => Promise - reset: () => Promise + /** Clear the current token and ask Turnstile for a new challenge. */ + reset: () => void + /** Reset, then resolve once a fresh token is available. */ + resetAndWaitForToken: () => Promise } interface TurnstileWidgetProps { @@ -73,8 +76,12 @@ const TurnstileWidget = forwardRef( if (widgetIdRef.current && window.turnstile) { window.turnstile.reset(widgetIdRef.current) } + }, [setToken]) + + const resetAndWaitForToken = useCallback(() => { + reset() return waitForToken() - }, [setToken, waitForToken]) + }, [reset, waitForToken]) useImperativeHandle( ref, @@ -82,8 +89,9 @@ const TurnstileWidget = forwardRef( getToken: () => tokenRef.current, waitForToken, reset, + resetAndWaitForToken, }), - [reset, waitForToken] + [reset, resetAndWaitForToken, waitForToken] ) useEffect(() => { @@ -110,6 +118,7 @@ const TurnstileWidget = forwardRef( window.turnstile.remove(widgetIdRef.current) widgetIdRef.current = undefined } + setToken(undefined) } }, [scriptReady, setToken]) diff --git a/utils/application-submission.test.js b/utils/application-submission.test.js index 215094703..eb3c675e6 100644 --- a/utils/application-submission.test.js +++ b/utils/application-submission.test.js @@ -8,15 +8,17 @@ describe('submitApplication', () => { function turnstileMock(tokens) { let index = 0 - return { - waitForToken: jest.fn( - async () => tokens[Math.min(index, tokens.length - 1)] - ), - reset: jest.fn(async () => { - index += 1 - return tokens[Math.min(index, tokens.length - 1)] - }), - } + const waitForToken = jest.fn( + async () => tokens[Math.min(index, tokens.length - 1)] + ) + const reset = jest.fn(() => { + index += 1 + }) + const resetAndWaitForToken = jest.fn(async () => { + reset() + return waitForToken() + }) + return { waitForToken, reset, resetAndWaitForToken } } it('creates the GitHub record before sending email, with fresh tokens', async () => { @@ -35,7 +37,7 @@ describe('submitApplication', () => { ]) expect(postJSON.mock.calls[0][1]['cf-turnstile-response']).toBe('token-1') expect(postJSON.mock.calls[1][1]['cf-turnstile-response']).toBe('token-2') - expect(turnstile.reset).toHaveBeenCalledTimes(1) + expect(turnstile.resetAndWaitForToken).toHaveBeenCalledTimes(1) }) it('succeeds when GitHub is confirmed even if email fails', async () => { @@ -68,6 +70,7 @@ describe('submitApplication', () => { expect.objectContaining({ 'cf-turnstile-response': 'token-1' }) ) expect(turnstile.reset).toHaveBeenCalled() + expect(turnstile.resetAndWaitForToken).not.toHaveBeenCalled() }) it('forces a retry when GitHub returns a non-success response', async () => { @@ -87,6 +90,7 @@ describe('submitApplication', () => { const turnstile = { waitForToken: jest.fn(async () => ''), reset: jest.fn(), + resetAndWaitForToken: jest.fn(), } await expect( diff --git a/utils/application-submission.ts b/utils/application-submission.ts index ef37da555..6a4aabe88 100644 --- a/utils/application-submission.ts +++ b/utils/application-submission.ts @@ -16,7 +16,8 @@ type PostJSON = ( export interface TurnstileControls { waitForToken: () => Promise - reset: () => Promise + reset: () => void + resetAndWaitForToken: () => Promise } export interface ApplicationDeliveryResult { @@ -74,14 +75,12 @@ export async function submitApplication( withTurnstileToken(data, githubToken) ) if (!github) { - if (turnstile) { - void turnstile.reset() - } + turnstile?.reset() throw new Error(SUBMISSION_ERROR) } const emailToken = turnstile - ? await turnstile.reset() + ? await turnstile.resetAndWaitForToken() : String(data[TURNSTILE_TOKEN_FIELD] || '') const email = await postSucceeded( From ec50f201d8f9a8dd31b268e297da549c62b7e4af Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 8 Aug 2026 11:18:49 +0100 Subject: [PATCH 07/18] fix: explain disabled submit when Turnstile is not ready --- .../MultiStepApplicationForm.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/components/grant-application/MultiStepApplicationForm.tsx b/components/grant-application/MultiStepApplicationForm.tsx index 289c0c88e..e9d8ceb48 100644 --- a/components/grant-application/MultiStepApplicationForm.tsx +++ b/components/grant-application/MultiStepApplicationForm.tsx @@ -154,10 +154,19 @@ export default function MultiStepApplicationForm({ {steps[currentStep].render(stepProps)} {isLastStep && ( - setTurnstileReady(!!token)} - /> +
+ setTurnstileReady(!!token)} + /> + {!turnstileReady && ( +

+ Complete the bot check above to enable submit. If nothing appears, + allow Cloudflare challenges or confirm this domain is listed on + the Turnstile widget. +

+ )} +
)} Date: Sat, 8 Aug 2026 11:28:40 +0100 Subject: [PATCH 08/18] chore: update Turnstile site key after domain refresh --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 7e83377b1..2fa352634 100644 --- a/.env.example +++ b/.env.example @@ -17,7 +17,7 @@ NEXT_PUBLIC_GOOGLE_DOC_ID= NEXT_PUBLIC_GOOGLE_API_KEY= # Cloudflare Turnstile (grant application forms) -NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAAEKByBDYEcxiPW-n +NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAAEKG6l3R_rWeoGYT TURNSTILE_SECRET= # Fund-specific webhook secrets From 5c2bc13cc78f687860a8715dacf9de17a696cd2f Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 8 Aug 2026 11:35:49 +0100 Subject: [PATCH 09/18] fix: use implicit Turnstile render for dynamic grant forms Explicit render never initialized when Script onLoad missed under Next.js dynamic imports, leaving submit disabled with no widget. --- .../grant-application/TurnstileWidget.tsx | 96 +++++++------------ 1 file changed, 37 insertions(+), 59 deletions(-) diff --git a/components/grant-application/TurnstileWidget.tsx b/components/grant-application/TurnstileWidget.tsx index 726a64adc..0a4a31ca2 100644 --- a/components/grant-application/TurnstileWidget.tsx +++ b/components/grant-application/TurnstileWidget.tsx @@ -5,28 +5,23 @@ import { useEffect, useImperativeHandle, useRef, - useState, } from 'react' const TURNSTILE_SCRIPT = 'https://challenges.cloudflare.com/turnstile/v0/api.js' const SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY +const CALLBACK_NAME = '__opensatsTurnstileCallback' +const EXPIRED_CALLBACK_NAME = '__opensatsTurnstileExpired' +const ERROR_CALLBACK_NAME = '__opensatsTurnstileError' declare global { interface Window { turnstile?: { - render: ( - element: HTMLElement, - options: { - sitekey: string - action?: string - callback?: (token: string) => void - 'expired-callback'?: () => void - 'error-callback'?: () => void - } - ) => string reset: (widgetId?: string) => void - remove: (widgetId?: string) => void + ready?: (callback: () => void) => void } + [CALLBACK_NAME]?: (token: string) => void + [EXPIRED_CALLBACK_NAME]?: () => void + [ERROR_CALLBACK_NAME]?: () => void } } @@ -43,26 +38,28 @@ interface TurnstileWidgetProps { onTokenChange?: (token: string | undefined) => void } +/** + * Implicit-render Turnstile widget (Spin contract). + * Uses api.js without `render=explicit` so Cloudflare auto-mounts + * `.cf-turnstile` after the script loads — works with Next.js dynamic imports + * where Script `onLoad` can miss. + */ const TurnstileWidget = forwardRef( function TurnstileWidget({ onTokenChange }, ref) { - const containerRef = useRef(null) - const widgetIdRef = useRef() const tokenRef = useRef() const waitersRef = useRef void>>([]) - const [scriptReady, setScriptReady] = useState(false) - - const setToken = useCallback( - (token: string | undefined) => { - tokenRef.current = token - onTokenChange?.(token) - if (token) { - const waiters = waitersRef.current - waitersRef.current = [] - waiters.forEach((resolve) => resolve(token)) - } - }, - [onTokenChange] - ) + const onTokenChangeRef = useRef(onTokenChange) + onTokenChangeRef.current = onTokenChange + + const setToken = useCallback((token: string | undefined) => { + tokenRef.current = token + onTokenChangeRef.current?.(token) + if (token) { + const waiters = waitersRef.current + waitersRef.current = [] + waiters.forEach((resolve) => resolve(token)) + } + }, []) const waitForToken = useCallback(() => { if (tokenRef.current) return Promise.resolve(tokenRef.current) @@ -73,9 +70,7 @@ const TurnstileWidget = forwardRef( const reset = useCallback(() => { setToken(undefined) - if (widgetIdRef.current && window.turnstile) { - window.turnstile.reset(widgetIdRef.current) - } + window.turnstile?.reset() }, [setToken]) const resetAndWaitForToken = useCallback(() => { @@ -95,32 +90,17 @@ const TurnstileWidget = forwardRef( ) useEffect(() => { - if ( - !scriptReady || - !SITE_KEY || - !containerRef.current || - !window.turnstile - ) { - return - } - if (widgetIdRef.current) return - - widgetIdRef.current = window.turnstile.render(containerRef.current, { - sitekey: SITE_KEY, - action: 'turnstile-spin-v2', - callback: (token) => setToken(token), - 'expired-callback': () => setToken(undefined), - 'error-callback': () => setToken(undefined), - }) + window[CALLBACK_NAME] = (token: string) => setToken(token) + window[EXPIRED_CALLBACK_NAME] = () => setToken(undefined) + window[ERROR_CALLBACK_NAME] = () => setToken(undefined) return () => { - if (widgetIdRef.current && window.turnstile) { - window.turnstile.remove(widgetIdRef.current) - widgetIdRef.current = undefined - } + delete window[CALLBACK_NAME] + delete window[EXPIRED_CALLBACK_NAME] + delete window[ERROR_CALLBACK_NAME] setToken(undefined) } - }, [scriptReady, setToken]) + }, [setToken]) if (!SITE_KEY) { return ( @@ -132,16 +112,14 @@ const TurnstileWidget = forwardRef( return ( <> -