@10x-media/form-builder 0.1.0-beta.2 → 0.1.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (272) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +4 -4
  3. package/dist/actions/body/converters.d.ts +20 -0
  4. package/dist/actions/body/converters.js +57 -0
  5. package/dist/actions/body/converters.js.map +1 -0
  6. package/dist/actions/body/escapeHtml.d.ts +6 -0
  7. package/dist/actions/body/escapeHtml.js +14 -0
  8. package/dist/actions/body/escapeHtml.js.map +1 -0
  9. package/dist/actions/body/serializeBody.d.ts +50 -0
  10. package/dist/actions/body/serializeBody.js +78 -0
  11. package/dist/actions/body/serializeBody.js.map +1 -0
  12. package/dist/actions/body/serializeSlate.js +31 -0
  13. package/dist/actions/body/serializeSlate.js.map +1 -0
  14. package/dist/actions/body/wildcards.d.ts +10 -0
  15. package/dist/actions/body/wildcards.js +33 -0
  16. package/dist/actions/body/wildcards.js.map +1 -0
  17. package/dist/actions/builtin/confirmation.js +40 -15
  18. package/dist/actions/builtin/confirmation.js.map +1 -1
  19. package/dist/actions/builtin/emailTeam.js +19 -12
  20. package/dist/actions/builtin/emailTeam.js.map +1 -1
  21. package/dist/actions/builtin/index.d.ts +13 -1
  22. package/dist/actions/builtin/index.js +18 -7
  23. package/dist/actions/builtin/index.js.map +1 -1
  24. package/dist/actions/builtin/signedWebhook.js +6 -2
  25. package/dist/actions/builtin/signedWebhook.js.map +1 -1
  26. package/dist/actions/defineAction.d.ts +2 -1
  27. package/dist/actions/defineAction.js.map +1 -1
  28. package/dist/actions/dispatch.js +2 -1
  29. package/dist/actions/dispatch.js.map +1 -1
  30. package/dist/actions/runActions.js +10 -1
  31. package/dist/actions/runActions.js.map +1 -1
  32. package/dist/actions/task.js +7 -5
  33. package/dist/actions/task.js.map +1 -1
  34. package/dist/aggregation/resolveResultsRequest.d.ts +22 -6
  35. package/dist/aggregation/resolveResultsRequest.js +18 -7
  36. package/dist/aggregation/resolveResultsRequest.js.map +1 -1
  37. package/dist/calc/normalizeCalc.js +5 -17
  38. package/dist/calc/normalizeCalc.js.map +1 -1
  39. package/dist/calc/types.d.ts +8 -2
  40. package/dist/calc/types.js +22 -0
  41. package/dist/calc/types.js.map +1 -0
  42. package/dist/client/ByteSizeField.d.ts +21 -0
  43. package/dist/client/ByteSizeField.js +56 -0
  44. package/dist/client/ByteSizeField.js.map +1 -0
  45. package/dist/client/ConditionBuilder.js +2 -1
  46. package/dist/client/ConditionBuilder.js.map +1 -1
  47. package/dist/client/FieldNameSelect.d.ts +20 -0
  48. package/dist/client/FieldNameSelect.js +58 -0
  49. package/dist/client/FieldNameSelect.js.map +1 -0
  50. package/dist/client/FlowBuilder.d.ts +0 -1
  51. package/dist/client/FlowBuilder.js +102 -59
  52. package/dist/client/FlowBuilder.js.map +1 -1
  53. package/dist/client/FormConditionField.js +1 -4
  54. package/dist/client/FormConditionField.js.map +1 -1
  55. package/dist/client/admin.css +234 -0
  56. package/dist/client/flowAuthoring.js +35 -0
  57. package/dist/client/flowAuthoring.js.map +1 -0
  58. package/dist/client/synthesizeClientField.js +6 -2
  59. package/dist/client/synthesizeClientField.js.map +1 -1
  60. package/dist/client/toStaticLabel.js +13 -0
  61. package/dist/client/toStaticLabel.js.map +1 -0
  62. package/dist/collections/formSubmissions.js +49 -9
  63. package/dist/collections/formSubmissions.js.map +1 -1
  64. package/dist/collections/forms.js +251 -88
  65. package/dist/collections/forms.js.map +1 -1
  66. package/dist/conditions/conditionType.js +9 -2
  67. package/dist/conditions/conditionType.js.map +1 -1
  68. package/dist/conditions/normalizeConditions.js +2 -1
  69. package/dist/conditions/normalizeConditions.js.map +1 -1
  70. package/dist/consent/builtin/index.d.ts +11 -1
  71. package/dist/consent/builtin/index.js +15 -5
  72. package/dist/consent/builtin/index.js.map +1 -1
  73. package/dist/consent/builtin/static.js +7 -3
  74. package/dist/consent/builtin/static.js.map +1 -1
  75. package/dist/exports/client.d.ts +3 -1
  76. package/dist/exports/client.js +3 -1
  77. package/dist/exports/react.d.ts +15 -6
  78. package/dist/exports/react.js +10 -2
  79. package/dist/exports/rsc.d.ts +6 -1
  80. package/dist/exports/rsc.js +6 -1
  81. package/dist/exports/types.d.ts +4 -4
  82. package/dist/fields/buildFieldBlocks.js +20 -15
  83. package/dist/fields/buildFieldBlocks.js.map +1 -1
  84. package/dist/fields/builtin/calculation.js +115 -2
  85. package/dist/fields/builtin/calculation.js.map +1 -1
  86. package/dist/fields/builtin/consent.js +8 -4
  87. package/dist/fields/builtin/consent.js.map +1 -1
  88. package/dist/fields/builtin/date.js +29 -0
  89. package/dist/fields/builtin/date.js.map +1 -0
  90. package/dist/fields/builtin/file.d.ts +15 -0
  91. package/dist/fields/builtin/file.js +87 -7
  92. package/dist/fields/builtin/file.js.map +1 -1
  93. package/dist/fields/builtin/index.d.ts +24 -0
  94. package/dist/fields/builtin/index.js +29 -8
  95. package/dist/fields/builtin/index.js.map +1 -1
  96. package/dist/fields/builtin/message.js +26 -0
  97. package/dist/fields/builtin/message.js.map +1 -0
  98. package/dist/fields/builtin/repeater.js +7 -3
  99. package/dist/fields/builtin/repeater.js.map +1 -1
  100. package/dist/fields/builtin/select.js +7 -3
  101. package/dist/fields/builtin/select.js.map +1 -1
  102. package/dist/fields/fieldNamesOfType.js +25 -0
  103. package/dist/fields/fieldNamesOfType.js.map +1 -0
  104. package/dist/fields/localizedIf.d.ts +13 -0
  105. package/dist/fields/localizedIf.js +12 -0
  106. package/dist/fields/localizedIf.js.map +1 -0
  107. package/dist/fields/sharedConfig.js +82 -57
  108. package/dist/fields/sharedConfig.js.map +1 -1
  109. package/dist/fields/types.d.ts +7 -2
  110. package/dist/flow/normalizeFlow.js +7 -1
  111. package/dist/flow/normalizeFlow.js.map +1 -1
  112. package/dist/flow/stepLabel.js +15 -0
  113. package/dist/flow/stepLabel.js.map +1 -0
  114. package/dist/form/pollState.d.ts +22 -0
  115. package/dist/form/pollState.js +17 -0
  116. package/dist/form/pollState.js.map +1 -0
  117. package/dist/form/toFormDocument.d.ts +60 -0
  118. package/dist/form/toFormDocument.js +39 -0
  119. package/dist/form/toFormDocument.js.map +1 -0
  120. package/dist/form/types.d.ts +48 -0
  121. package/dist/index.d.ts +81 -13
  122. package/dist/index.js +36 -15
  123. package/dist/index.js.map +1 -1
  124. package/dist/plugin/registerCollections.js +17 -37
  125. package/dist/plugin/registerCollections.js.map +1 -1
  126. package/dist/plugin/uploadsCollection.d.ts +11 -0
  127. package/dist/plugin/uploadsCollection.js +44 -0
  128. package/dist/plugin/uploadsCollection.js.map +1 -0
  129. package/dist/poll/applyPollOptions.js +17 -0
  130. package/dist/poll/applyPollOptions.js.map +1 -0
  131. package/dist/poll/buildPollOptionSourceFields.js +53 -0
  132. package/dist/poll/buildPollOptionSourceFields.js.map +1 -0
  133. package/dist/poll/definePollOptionSource.d.ts +35 -0
  134. package/dist/poll/definePollOptionSource.js +6 -0
  135. package/dist/poll/definePollOptionSource.js.map +1 -0
  136. package/dist/poll/registry.d.ts +16 -0
  137. package/dist/poll/registry.js +16 -0
  138. package/dist/poll/registry.js.map +1 -0
  139. package/dist/poll/resolvePollOptions.d.ts +28 -0
  140. package/dist/poll/resolvePollOptions.js +64 -0
  141. package/dist/poll/resolvePollOptions.js.map +1 -0
  142. package/dist/poll/resolvePollOutcome.d.ts +21 -0
  143. package/dist/poll/resolvePollOutcome.js +36 -0
  144. package/dist/poll/resolvePollOutcome.js.map +1 -0
  145. package/dist/prefill/valuesFromSearchParams.js +1 -1
  146. package/dist/prefill/valuesFromSearchParams.js.map +1 -1
  147. package/dist/presentations/types.d.ts +3 -2
  148. package/dist/react/Form.d.ts +13 -31
  149. package/dist/react/Form.js +98 -85
  150. package/dist/react/Form.js.map +1 -1
  151. package/dist/react/FormContext.d.ts +35 -1
  152. package/dist/react/FormContext.js.map +1 -1
  153. package/dist/react/FormControls.d.ts +55 -0
  154. package/dist/react/FormControls.js +56 -0
  155. package/dist/react/FormControls.js.map +1 -0
  156. package/dist/react/FormResults.d.ts +6 -3
  157. package/dist/react/FormResults.js +37 -29
  158. package/dist/react/FormResults.js.map +1 -1
  159. package/dist/react/FormSteps.d.ts +17 -0
  160. package/dist/react/FormSteps.js +35 -0
  161. package/dist/react/FormSteps.js.map +1 -0
  162. package/dist/react/Poll.d.ts +17 -6
  163. package/dist/react/Poll.js +66 -18
  164. package/dist/react/Poll.js.map +1 -1
  165. package/dist/react/captcha/HcaptchaCaptcha.d.ts +28 -0
  166. package/dist/react/captcha/HcaptchaCaptcha.js +53 -0
  167. package/dist/react/captcha/HcaptchaCaptcha.js.map +1 -0
  168. package/dist/react/captcha/RecaptchaCaptcha.d.ts +41 -0
  169. package/dist/react/captcha/RecaptchaCaptcha.js +93 -0
  170. package/dist/react/captcha/RecaptchaCaptcha.js.map +1 -0
  171. package/dist/react/captcha/TurnstileCaptcha.d.ts +28 -0
  172. package/dist/react/captcha/TurnstileCaptcha.js +53 -0
  173. package/dist/react/captcha/TurnstileCaptcha.js.map +1 -0
  174. package/dist/react/captcha/types.d.ts +8 -0
  175. package/dist/react/captcha/useCaptchaScript.js +45 -0
  176. package/dist/react/captcha/useCaptchaScript.js.map +1 -0
  177. package/dist/react/captcha/vendors.js +8 -0
  178. package/dist/react/captcha/vendors.js.map +1 -0
  179. package/dist/react/fetchResults.d.ts +1 -1
  180. package/dist/react/fetchResults.js +1 -1
  181. package/dist/react/fetchResults.js.map +1 -1
  182. package/dist/react/primitives/Input.d.ts +1 -1
  183. package/dist/react/primitives/Input.js.map +1 -1
  184. package/dist/react/renderers/calculation.js +1 -1
  185. package/dist/react/renderers/checkbox.js +1 -1
  186. package/dist/react/renderers/consent.js +1 -1
  187. package/dist/react/renderers/date.js +37 -0
  188. package/dist/react/renderers/date.js.map +1 -0
  189. package/dist/react/renderers/email.js +1 -1
  190. package/dist/react/renderers/file.js +25 -8
  191. package/dist/react/renderers/file.js.map +1 -1
  192. package/dist/react/renderers/index.js +4 -0
  193. package/dist/react/renderers/index.js.map +1 -1
  194. package/dist/react/renderers/message.js +35 -0
  195. package/dist/react/renderers/message.js.map +1 -0
  196. package/dist/react/renderers/number.js +1 -1
  197. package/dist/react/renderers/repeater.js +1 -1
  198. package/dist/react/renderers/select.js +1 -1
  199. package/dist/react/renderers/text.js +1 -1
  200. package/dist/react/renderers/textarea.js +1 -1
  201. package/dist/react/state.d.ts +25 -1
  202. package/dist/react/state.js +14 -1
  203. package/dist/react/state.js.map +1 -1
  204. package/dist/react/uploadFile.d.ts +2 -2
  205. package/dist/react/uploadFile.js +1 -1
  206. package/dist/react/uploadFile.js.map +1 -1
  207. package/dist/spam/captcha.d.ts +3 -2
  208. package/dist/spam/captcha.js +3 -2
  209. package/dist/spam/captcha.js.map +1 -1
  210. package/dist/spam/clientIp.js +9 -0
  211. package/dist/spam/clientIp.js.map +1 -0
  212. package/dist/spam/identify.js +3 -6
  213. package/dist/spam/identify.js.map +1 -1
  214. package/dist/spam/providers/hcaptcha.d.ts +17 -0
  215. package/dist/spam/providers/hcaptcha.js +18 -0
  216. package/dist/spam/providers/hcaptcha.js.map +1 -0
  217. package/dist/spam/providers/recaptcha.d.ts +23 -0
  218. package/dist/spam/providers/recaptcha.js +23 -0
  219. package/dist/spam/providers/recaptcha.js.map +1 -0
  220. package/dist/spam/providers/siteverify.js +34 -0
  221. package/dist/spam/providers/siteverify.js.map +1 -0
  222. package/dist/spam/providers/turnstile.d.ts +17 -0
  223. package/dist/spam/providers/turnstile.js +18 -0
  224. package/dist/spam/providers/turnstile.js.map +1 -0
  225. package/dist/spam/spamGuard.js +2 -5
  226. package/dist/spam/spamGuard.js.map +1 -1
  227. package/dist/spam/types.d.ts +2 -2
  228. package/dist/submissions/runSubmission.js +70 -33
  229. package/dist/submissions/runSubmission.js.map +1 -1
  230. package/dist/submissions/validateSubmission.js +41 -7
  231. package/dist/submissions/validateSubmission.js.map +1 -1
  232. package/dist/submissions/votedCookie.d.ts +13 -0
  233. package/dist/submissions/votedCookie.js +27 -0
  234. package/dist/submissions/votedCookie.js.map +1 -0
  235. package/dist/translations/de.js +219 -0
  236. package/dist/translations/de.js.map +1 -0
  237. package/dist/translations/en.js +71 -10
  238. package/dist/translations/en.js.map +1 -1
  239. package/dist/translations/index.d.ts +2 -1
  240. package/dist/translations/index.js +6 -2
  241. package/dist/translations/index.js.map +1 -1
  242. package/dist/translations/keys.d.ts +69 -8
  243. package/dist/translations/keys.js +70 -9
  244. package/dist/translations/keys.js.map +1 -1
  245. package/dist/uploads/formatBytes.d.ts +9 -0
  246. package/dist/uploads/formatBytes.js +25 -0
  247. package/dist/uploads/formatBytes.js.map +1 -0
  248. package/dist/uploads/resolveFileRef.js +1 -1
  249. package/dist/uploads/resolveFileRef.js.map +1 -1
  250. package/dist/uploads/types.d.ts +1 -2
  251. package/dist/validation/builtin/index.d.ts +14 -0
  252. package/dist/validation/builtin/index.js +12 -1
  253. package/dist/validation/builtin/index.js.map +1 -1
  254. package/dist/validation/builtin/maxDate.js +23 -0
  255. package/dist/validation/builtin/maxDate.js.map +1 -0
  256. package/dist/validation/builtin/minDate.js +23 -0
  257. package/dist/validation/builtin/minDate.js.map +1 -0
  258. package/dist/validation/builtin/validateDateBound.js +18 -0
  259. package/dist/validation/builtin/validateDateBound.js.map +1 -0
  260. package/dist/validation/validateUrl.js +22 -0
  261. package/dist/validation/validateUrl.js.map +1 -0
  262. package/package.json +4 -2
  263. package/styles/form-builder.css +10 -190
  264. package/dist/collections/uploads.d.ts +0 -30
  265. package/dist/collections/uploads.js +0 -55
  266. package/dist/collections/uploads.js.map +0 -1
  267. package/dist/presentations/registry.d.ts +0 -15
  268. package/dist/presentations/registry.js +0 -15
  269. package/dist/presentations/registry.js.map +0 -1
  270. package/dist/react/toFormDocument.d.ts +0 -24
  271. package/dist/react/toFormDocument.js +0 -22
  272. package/dist/react/toFormDocument.js.map +0 -1
@@ -0,0 +1,35 @@
1
+ "use client";
2
+ import { serializeBody } from "../../actions/body/serializeBody.js";
3
+ import { defineFieldRenderer } from "../contract.js";
4
+ import { FormContext } from "../FormContext.js";
5
+ import { jsx } from "react/jsx-runtime";
6
+ import { useContext, useMemo } from "react";
7
+ //#region src/react/renderers/message.tsx
8
+ /**
9
+ * Display-only renderer for a `message` field: serializes the authored `content` rich text to HTML
10
+ * inline between fields. Recall tokens (`{{name}}`) resolve against the current answers (calc
11
+ * values included). Never binds form state; the field writes nothing to submissions.
12
+ */
13
+ const messageRenderer = defineFieldRenderer(({ field }) => {
14
+ const context = useContext(FormContext);
15
+ const values = context?.effectiveValues ?? context?.state.values;
16
+ const html = useMemo(() => {
17
+ const answered = Object.entries(values ?? {}).filter(([, value]) => value != null && value !== "").map(([name, value]) => ({
18
+ field: name,
19
+ value
20
+ }));
21
+ return serializeBody(field.content, {
22
+ values: answered,
23
+ descriptors: []
24
+ });
25
+ }, [field.content, values]);
26
+ if (html === "") return null;
27
+ return /* @__PURE__ */ jsx("div", {
28
+ className: "fb-field__message",
29
+ dangerouslySetInnerHTML: { __html: html }
30
+ });
31
+ });
32
+ //#endregion
33
+ export { messageRenderer };
34
+
35
+ //# sourceMappingURL=message.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"message.js","names":[],"sources":["../../../src/react/renderers/message.tsx"],"sourcesContent":["'use client'\n\nimport { useContext, useMemo } from 'react'\nimport { serializeBody } from '../../actions/body/serializeBody'\nimport type { SubmissionValue } from '../../submissions/types'\nimport { defineFieldRenderer } from '../contract'\nimport { FormContext } from '../FormContext'\n\n/**\n * Display-only renderer for a `message` field: serializes the authored `content` rich text to HTML\n * inline between fields. Recall tokens (`{{name}}`) resolve against the current answers (calc\n * values included). Never binds form state; the field writes nothing to submissions.\n */\nexport const messageRenderer = defineFieldRenderer(({ field }) => {\n\tconst context = useContext(FormContext)\n\tconst values = context?.effectiveValues ?? context?.state.values\n\tconst html = useMemo(() => {\n\t\tconst answered: SubmissionValue[] = Object.entries(values ?? {})\n\t\t\t.filter(([, value]) => value != null && value !== '')\n\t\t\t.map(([name, value]) => ({ field: name, value }))\n\t\treturn serializeBody(field.content, { values: answered, descriptors: [] })\n\t}, [field.content, values])\n\tif (html === '') {\n\t\treturn null\n\t}\n\t// Safe to inject: serializeBody HTML-escapes all text (recall values included) and sanitizes link URLs.\n\t// biome-ignore lint/security/noDangerouslySetInnerHtml: HTML is produced by our escaping serializer, never raw user input\n\treturn <div className=\"fb-field__message\" dangerouslySetInnerHTML={{ __html: html }} />\n})\n"],"mappings":";;;;;;;;;;;;AAaA,MAAa,kBAAkB,qBAAqB,EAAE,YAAY;CACjE,MAAM,UAAU,WAAW,WAAW;CACtC,MAAM,SAAS,SAAS,mBAAmB,SAAS,MAAM;CAC1D,MAAM,OAAO,cAAc;EAC1B,MAAM,WAA8B,OAAO,QAAQ,UAAU,CAAC,CAAC,EAC7D,QAAQ,GAAG,WAAW,SAAS,QAAQ,UAAU,EAAE,EACnD,KAAK,CAAC,MAAM,YAAY;GAAE,OAAO;GAAM;EAAM,EAAE;EACjD,OAAO,cAAc,MAAM,SAAS;GAAE,QAAQ;GAAU,aAAa,CAAC;EAAE,CAAC;CAC1E,GAAG,CAAC,MAAM,SAAS,MAAM,CAAC;CAC1B,IAAI,SAAS,IACZ,OAAO;CAIR,OAAO,oBAAC,OAAD;EAAK,WAAU;EAAoB,yBAAyB,EAAE,QAAQ,KAAK;CAAI,CAAA;AACvF,CAAC"}
@@ -2,8 +2,8 @@
2
2
  import { defineFieldRenderer } from "../contract.js";
3
3
  import { FieldShell } from "../primitives/FieldShell.js";
4
4
  import { Input } from "../primitives/Input.js";
5
- import { useId } from "react";
6
5
  import { jsx } from "react/jsx-runtime";
6
+ import { useId } from "react";
7
7
  //#region src/react/renderers/number.tsx
8
8
  const numberRenderer = defineFieldRenderer(({ field, name, value, onChange, onBlur, errors, warnings, required, disabled }) => {
9
9
  const id = useId();
@@ -4,8 +4,8 @@ import { defineFieldRenderer } from "../contract.js";
4
4
  import { useFormContext } from "../FormContext.js";
5
5
  import { FieldShell } from "../primitives/FieldShell.js";
6
6
  import { useField } from "../useField.js";
7
- import { createElement, useId } from "react";
8
7
  import { jsx, jsxs } from "react/jsx-runtime";
8
+ import { createElement, useId } from "react";
9
9
  //#region src/react/renderers/repeater.tsx
10
10
  /**
11
11
  * Thin wrapper so each sub-field renderer is a proper React component (has stable hook identity
@@ -2,8 +2,8 @@
2
2
  import { defineFieldRenderer } from "../contract.js";
3
3
  import { FieldShell } from "../primitives/FieldShell.js";
4
4
  import { Select } from "../primitives/Select.js";
5
- import { useId } from "react";
6
5
  import { jsx } from "react/jsx-runtime";
6
+ import { useId } from "react";
7
7
  //#region src/react/renderers/select.tsx
8
8
  const selectRenderer = defineFieldRenderer(({ field, name, value, onChange, onBlur, errors, warnings, required, disabled }) => {
9
9
  const id = useId();
@@ -2,8 +2,8 @@
2
2
  import { defineFieldRenderer } from "../contract.js";
3
3
  import { FieldShell } from "../primitives/FieldShell.js";
4
4
  import { Input } from "../primitives/Input.js";
5
- import { useId } from "react";
6
5
  import { jsx } from "react/jsx-runtime";
6
+ import { useId } from "react";
7
7
  //#region src/react/renderers/text.tsx
8
8
  const textRenderer = defineFieldRenderer(({ field, name, value, onChange, onBlur, errors, warnings, required, disabled }) => {
9
9
  const id = useId();
@@ -2,8 +2,8 @@
2
2
  import { defineFieldRenderer } from "../contract.js";
3
3
  import { FieldShell } from "../primitives/FieldShell.js";
4
4
  import { Textarea } from "../primitives/Textarea.js";
5
- import { useId } from "react";
6
5
  import { jsx } from "react/jsx-runtime";
6
+ import { useId } from "react";
7
7
  //#region src/react/renderers/textarea.tsx
8
8
  const textareaRenderer = defineFieldRenderer(({ field, name, value, onChange, onBlur, errors, warnings, required, disabled }) => {
9
9
  const id = useId();
@@ -10,6 +10,30 @@ type FormState = {
10
10
  submitAttempted: boolean;
11
11
  submitError?: string;
12
12
  };
13
+ type FormAction = {
14
+ type: 'SET_VALUE';
15
+ name: string;
16
+ value: unknown;
17
+ } | {
18
+ type: 'TOUCH';
19
+ name: string;
20
+ } | {
21
+ type: 'SET_FIELD_ISSUES';
22
+ name: string;
23
+ errors: string[];
24
+ warnings: string[];
25
+ } | {
26
+ type: 'SET_ALL_ISSUES';
27
+ errors: FieldErrors;
28
+ warnings: FieldErrors;
29
+ } | {
30
+ type: 'SUBMIT_START';
31
+ } | {
32
+ type: 'SUBMIT_SUCCESS';
33
+ } | {
34
+ type: 'SUBMIT_ERROR';
35
+ message: string;
36
+ };
13
37
  //#endregion
14
- export { FormState };
38
+ export { FieldErrors, FormAction, FormState };
15
39
  //# sourceMappingURL=state.d.ts.map
@@ -1,4 +1,17 @@
1
1
  //#region src/react/state.ts
2
+ /**
3
+ * Per-field defaults for the reducer's initial state. A repeater with a positive `minRows` starts
4
+ * pre-seeded with that many empty rows, matching the schema's own floor. Computed once, ahead of the
5
+ * reducer, so seeding is never an action: it can't touch a field, trigger validation, or (via `Form`'s
6
+ * dispatch wrapper) be mistaken for the user's first edit and fire `form.started`.
7
+ */
8
+ const seedFieldValues = (fields) => Object.fromEntries(fields.map((field) => {
9
+ if (field.blockType === "repeater") {
10
+ const minRows = typeof field.minRows === "number" ? field.minRows : 0;
11
+ if (minRows > 0) return [field.name, Array.from({ length: minRows }, () => ({}))];
12
+ }
13
+ return [field.name, void 0];
14
+ }));
2
15
  const initialFormState = (values) => ({
3
16
  values,
4
17
  errors: {},
@@ -65,6 +78,6 @@ const formReducer = (state, action) => {
65
78
  }
66
79
  };
67
80
  //#endregion
68
- export { formReducer, initialFormState };
81
+ export { formReducer, initialFormState, seedFieldValues };
69
82
 
70
83
  //# sourceMappingURL=state.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"state.js","names":[],"sources":["../../src/react/state.ts"],"sourcesContent":["export type FieldErrors = Record<string, string[]>\n\nexport type FormState = {\n\tvalues: Record<string, unknown>\n\terrors: FieldErrors\n\twarnings: FieldErrors\n\ttouched: Record<string, boolean>\n\tsubmitting: boolean\n\tsubmitted: boolean\n\tsubmitAttempted: boolean\n\tsubmitError?: string\n}\n\nexport type FormAction =\n\t| { type: 'SET_VALUE'; name: string; value: unknown }\n\t| { type: 'TOUCH'; name: string }\n\t| { type: 'SET_FIELD_ISSUES'; name: string; errors: string[]; warnings: string[] }\n\t| { type: 'SET_ALL_ISSUES'; errors: FieldErrors; warnings: FieldErrors }\n\t| { type: 'SUBMIT_START' }\n\t| { type: 'SUBMIT_SUCCESS' }\n\t| { type: 'SUBMIT_ERROR'; message: string }\n\nexport const initialFormState = (values: Record<string, unknown>): FormState => ({\n\tvalues,\n\terrors: {},\n\twarnings: {},\n\ttouched: {},\n\tsubmitting: false,\n\tsubmitted: false,\n\tsubmitAttempted: false,\n})\n\n/** Changing a value clears that field's prior errors (re-validated by the caller). */\nexport const formReducer = (state: FormState, action: FormAction): FormState => {\n\tswitch (action.type) {\n\t\tcase 'SET_VALUE': {\n\t\t\tconst { [action.name]: _removed, ...restErrors } = state.errors\n\t\t\treturn {\n\t\t\t\t...state,\n\t\t\t\tvalues: { ...state.values, [action.name]: action.value },\n\t\t\t\terrors: restErrors,\n\t\t\t}\n\t\t}\n\t\tcase 'TOUCH':\n\t\t\treturn state.touched[action.name]\n\t\t\t\t? state\n\t\t\t\t: { ...state, touched: { ...state.touched, [action.name]: true } }\n\t\tcase 'SET_FIELD_ISSUES':\n\t\t\treturn {\n\t\t\t\t...state,\n\t\t\t\terrors: { ...state.errors, [action.name]: action.errors },\n\t\t\t\twarnings: { ...state.warnings, [action.name]: action.warnings },\n\t\t\t}\n\t\tcase 'SET_ALL_ISSUES':\n\t\t\treturn { ...state, errors: action.errors, warnings: action.warnings, submitAttempted: true }\n\t\tcase 'SUBMIT_START':\n\t\t\treturn { ...state, submitting: true, submitError: undefined }\n\t\tcase 'SUBMIT_SUCCESS':\n\t\t\treturn { ...state, submitting: false, submitted: true }\n\t\tcase 'SUBMIT_ERROR':\n\t\t\treturn { ...state, submitting: false, submitError: action.message }\n\t\tdefault:\n\t\t\treturn state\n\t}\n}\n"],"mappings":";AAsBA,MAAa,oBAAoB,YAAgD;CAChF;CACA,QAAQ,CAAC;CACT,UAAU,CAAC;CACX,SAAS,CAAC;CACV,YAAY;CACZ,WAAW;CACX,iBAAiB;AAClB;;AAGA,MAAa,eAAe,OAAkB,WAAkC;CAC/E,QAAQ,OAAO,MAAf;EACC,KAAK,aAAa;GACjB,MAAM,GAAG,OAAO,OAAO,UAAU,GAAG,eAAe,MAAM;GACzD,OAAO;IACN,GAAG;IACH,QAAQ;KAAE,GAAG,MAAM;MAAS,OAAO,OAAO,OAAO;IAAM;IACvD,QAAQ;GACT;EACD;EACA,KAAK,SACJ,OAAO,MAAM,QAAQ,OAAO,QACzB,QACA;GAAE,GAAG;GAAO,SAAS;IAAE,GAAG,MAAM;KAAU,OAAO,OAAO;GAAK;EAAE;EACnE,KAAK,oBACJ,OAAO;GACN,GAAG;GACH,QAAQ;IAAE,GAAG,MAAM;KAAS,OAAO,OAAO,OAAO;GAAO;GACxD,UAAU;IAAE,GAAG,MAAM;KAAW,OAAO,OAAO,OAAO;GAAS;EAC/D;EACD,KAAK,kBACJ,OAAO;GAAE,GAAG;GAAO,QAAQ,OAAO;GAAQ,UAAU,OAAO;GAAU,iBAAiB;EAAK;EAC5F,KAAK,gBACJ,OAAO;GAAE,GAAG;GAAO,YAAY;GAAM,aAAa,KAAA;EAAU;EAC7D,KAAK,kBACJ,OAAO;GAAE,GAAG;GAAO,YAAY;GAAO,WAAW;EAAK;EACvD,KAAK,gBACJ,OAAO;GAAE,GAAG;GAAO,YAAY;GAAO,aAAa,OAAO;EAAQ;EACnE,SACC,OAAO;CACT;AACD"}
1
+ {"version":3,"file":"state.js","names":[],"sources":["../../src/react/state.ts"],"sourcesContent":["import type { FormFieldInstance } from '../submissions/types'\n\nexport type FieldErrors = Record<string, string[]>\n\nexport type FormState = {\n\tvalues: Record<string, unknown>\n\terrors: FieldErrors\n\twarnings: FieldErrors\n\ttouched: Record<string, boolean>\n\tsubmitting: boolean\n\tsubmitted: boolean\n\tsubmitAttempted: boolean\n\tsubmitError?: string\n}\n\nexport type FormAction =\n\t| { type: 'SET_VALUE'; name: string; value: unknown }\n\t| { type: 'TOUCH'; name: string }\n\t| { type: 'SET_FIELD_ISSUES'; name: string; errors: string[]; warnings: string[] }\n\t| { type: 'SET_ALL_ISSUES'; errors: FieldErrors; warnings: FieldErrors }\n\t| { type: 'SUBMIT_START' }\n\t| { type: 'SUBMIT_SUCCESS' }\n\t| { type: 'SUBMIT_ERROR'; message: string }\n\n/**\n * Per-field defaults for the reducer's initial state. A repeater with a positive `minRows` starts\n * pre-seeded with that many empty rows, matching the schema's own floor. Computed once, ahead of the\n * reducer, so seeding is never an action: it can't touch a field, trigger validation, or (via `Form`'s\n * dispatch wrapper) be mistaken for the user's first edit and fire `form.started`.\n */\nexport const seedFieldValues = (fields: FormFieldInstance[]): Record<string, unknown> =>\n\tObject.fromEntries(\n\t\tfields.map((field) => {\n\t\t\tif (field.blockType === 'repeater') {\n\t\t\t\tconst minRows = typeof field.minRows === 'number' ? field.minRows : 0\n\t\t\t\tif (minRows > 0) {\n\t\t\t\t\treturn [field.name, Array.from({ length: minRows }, () => ({}))]\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn [field.name, undefined]\n\t\t})\n\t)\n\nexport const initialFormState = (values: Record<string, unknown>): FormState => ({\n\tvalues,\n\terrors: {},\n\twarnings: {},\n\ttouched: {},\n\tsubmitting: false,\n\tsubmitted: false,\n\tsubmitAttempted: false,\n})\n\n/** Changing a value clears that field's prior errors (re-validated by the caller). */\nexport const formReducer = (state: FormState, action: FormAction): FormState => {\n\tswitch (action.type) {\n\t\tcase 'SET_VALUE': {\n\t\t\tconst { [action.name]: _removed, ...restErrors } = state.errors\n\t\t\treturn {\n\t\t\t\t...state,\n\t\t\t\tvalues: { ...state.values, [action.name]: action.value },\n\t\t\t\terrors: restErrors,\n\t\t\t}\n\t\t}\n\t\tcase 'TOUCH':\n\t\t\treturn state.touched[action.name]\n\t\t\t\t? state\n\t\t\t\t: { ...state, touched: { ...state.touched, [action.name]: true } }\n\t\tcase 'SET_FIELD_ISSUES':\n\t\t\treturn {\n\t\t\t\t...state,\n\t\t\t\terrors: { ...state.errors, [action.name]: action.errors },\n\t\t\t\twarnings: { ...state.warnings, [action.name]: action.warnings },\n\t\t\t}\n\t\tcase 'SET_ALL_ISSUES':\n\t\t\treturn { ...state, errors: action.errors, warnings: action.warnings, submitAttempted: true }\n\t\tcase 'SUBMIT_START':\n\t\t\treturn { ...state, submitting: true, submitError: undefined }\n\t\tcase 'SUBMIT_SUCCESS':\n\t\t\treturn { ...state, submitting: false, submitted: true }\n\t\tcase 'SUBMIT_ERROR':\n\t\t\treturn { ...state, submitting: false, submitError: action.message }\n\t\tdefault:\n\t\t\treturn state\n\t}\n}\n"],"mappings":";;;;;;;AA8BA,MAAa,mBAAmB,WAC/B,OAAO,YACN,OAAO,KAAK,UAAU;CACrB,IAAI,MAAM,cAAc,YAAY;EACnC,MAAM,UAAU,OAAO,MAAM,YAAY,WAAW,MAAM,UAAU;EACpE,IAAI,UAAU,GACb,OAAO,CAAC,MAAM,MAAM,MAAM,KAAK,EAAE,QAAQ,QAAQ,UAAU,CAAC,EAAE,CAAC;CAEjE;CACA,OAAO,CAAC,MAAM,MAAM,KAAA,CAAS;AAC9B,CAAC,CACF;AAED,MAAa,oBAAoB,YAAgD;CAChF;CACA,QAAQ,CAAC;CACT,UAAU,CAAC;CACX,SAAS,CAAC;CACV,YAAY;CACZ,WAAW;CACX,iBAAiB;AAClB;;AAGA,MAAa,eAAe,OAAkB,WAAkC;CAC/E,QAAQ,OAAO,MAAf;EACC,KAAK,aAAa;GACjB,MAAM,GAAG,OAAO,OAAO,UAAU,GAAG,eAAe,MAAM;GACzD,OAAO;IACN,GAAG;IACH,QAAQ;KAAE,GAAG,MAAM;MAAS,OAAO,OAAO,OAAO;IAAM;IACvD,QAAQ;GACT;EACD;EACA,KAAK,SACJ,OAAO,MAAM,QAAQ,OAAO,QACzB,QACA;GAAE,GAAG;GAAO,SAAS;IAAE,GAAG,MAAM;KAAU,OAAO,OAAO;GAAK;EAAE;EACnE,KAAK,oBACJ,OAAO;GACN,GAAG;GACH,QAAQ;IAAE,GAAG,MAAM;KAAS,OAAO,OAAO,OAAO;GAAO;GACxD,UAAU;IAAE,GAAG,MAAM;KAAW,OAAO,OAAO,OAAO;GAAS;EAC/D;EACD,KAAK,kBACJ,OAAO;GAAE,GAAG;GAAO,QAAQ,OAAO;GAAQ,UAAU,OAAO;GAAU,iBAAiB;EAAK;EAC5F,KAAK,gBACJ,OAAO;GAAE,GAAG;GAAO,YAAY;GAAM,aAAa,KAAA;EAAU;EAC7D,KAAK,kBACJ,OAAO;GAAE,GAAG;GAAO,YAAY;GAAO,WAAW;EAAK;EACvD,KAAK,gBACJ,OAAO;GAAE,GAAG;GAAO,YAAY;GAAO,aAAa,OAAO;EAAQ;EACnE,SACC,OAAO;CACT;AACD"}
@@ -1,7 +1,7 @@
1
1
  //#region src/react/uploadFile.d.ts
2
2
  type UploadFileInput = {
3
- file: File; /** Upload collection slug. Defaults to `form-uploads`. */
4
- collection?: string;
3
+ file: File; /** Upload collection slug. File fields carry it as the server-stamped `collection` block config. */
4
+ collection: string;
5
5
  apiRoute?: string;
6
6
  fetchImpl?: typeof fetch;
7
7
  };
@@ -5,7 +5,7 @@
5
5
  * at submit, so the client is never trusted for it. Pure: inject `fetchImpl` in tests.
6
6
  */
7
7
  const uploadFile = async (input) => {
8
- const { file, collection = "form-uploads", apiRoute = "/api", fetchImpl = fetch } = input;
8
+ const { file, collection, apiRoute = "/api", fetchImpl = fetch } = input;
9
9
  const body = new FormData();
10
10
  body.append("file", file);
11
11
  let response;
@@ -1 +1 @@
1
- {"version":3,"file":"uploadFile.js","names":[],"sources":["../../src/react/uploadFile.ts"],"sourcesContent":["export type UploadFileInput = {\n\tfile: File\n\t/** Upload collection slug. Defaults to `form-uploads`. */\n\tcollection?: string\n\tapiRoute?: string\n\tfetchImpl?: typeof fetch\n}\n\nexport type UploadFileResult = { ok: true; id: string | number } | { ok: false; message?: string }\n\n/**\n * Upload one file to a Payload upload collection (`POST {apiRoute}/{collection}`, multipart) and return its\n * id, which the file field stores as its value. The server re-derives the file metadata from the stored doc\n * at submit, so the client is never trusted for it. Pure: inject `fetchImpl` in tests.\n */\nexport const uploadFile = async (input: UploadFileInput): Promise<UploadFileResult> => {\n\tconst { file, collection = 'form-uploads', apiRoute = '/api', fetchImpl = fetch } = input\n\tconst body = new FormData()\n\tbody.append('file', file)\n\tlet response: Response\n\ttry {\n\t\tresponse = await fetchImpl(`${apiRoute}/${collection}`, { method: 'POST', body })\n\t} catch (error) {\n\t\treturn { ok: false, message: error instanceof Error ? error.message : 'Network error' }\n\t}\n\tif (!response.ok) {\n\t\treturn { ok: false, message: `Upload failed (${response.status})` }\n\t}\n\tconst data = (await response.json().catch(() => ({}))) as { doc?: { id?: string | number } }\n\tconst id = data.doc?.id\n\treturn id == null ? { ok: false, message: 'No id returned' } : { ok: true, id }\n}\n"],"mappings":";;;;;;AAeA,MAAa,aAAa,OAAO,UAAsD;CACtF,MAAM,EAAE,MAAM,aAAa,gBAAgB,WAAW,QAAQ,YAAY,UAAU;CACpF,MAAM,OAAO,IAAI,SAAS;CAC1B,KAAK,OAAO,QAAQ,IAAI;CACxB,IAAI;CACJ,IAAI;EACH,WAAW,MAAM,UAAU,GAAG,SAAS,GAAG,cAAc;GAAE,QAAQ;GAAQ;EAAK,CAAC;CACjF,SAAS,OAAO;EACf,OAAO;GAAE,IAAI;GAAO,SAAS,iBAAiB,QAAQ,MAAM,UAAU;EAAgB;CACvF;CACA,IAAI,CAAC,SAAS,IACb,OAAO;EAAE,IAAI;EAAO,SAAS,kBAAkB,SAAS,OAAO;CAAG;CAGnE,MAAM,MAAK,MADS,SAAS,KAAK,EAAE,aAAa,CAAC,EAAE,GACpC,KAAK;CACrB,OAAO,MAAM,OAAO;EAAE,IAAI;EAAO,SAAS;CAAiB,IAAI;EAAE,IAAI;EAAM;CAAG;AAC/E"}
1
+ {"version":3,"file":"uploadFile.js","names":[],"sources":["../../src/react/uploadFile.ts"],"sourcesContent":["export type UploadFileInput = {\n\tfile: File\n\t/** Upload collection slug. File fields carry it as the server-stamped `collection` block config. */\n\tcollection: string\n\tapiRoute?: string\n\tfetchImpl?: typeof fetch\n}\n\nexport type UploadFileResult = { ok: true; id: string | number } | { ok: false; message?: string }\n\n/**\n * Upload one file to a Payload upload collection (`POST {apiRoute}/{collection}`, multipart) and return its\n * id, which the file field stores as its value. The server re-derives the file metadata from the stored doc\n * at submit, so the client is never trusted for it. Pure: inject `fetchImpl` in tests.\n */\nexport const uploadFile = async (input: UploadFileInput): Promise<UploadFileResult> => {\n\tconst { file, collection, apiRoute = '/api', fetchImpl = fetch } = input\n\tconst body = new FormData()\n\tbody.append('file', file)\n\tlet response: Response\n\ttry {\n\t\tresponse = await fetchImpl(`${apiRoute}/${collection}`, { method: 'POST', body })\n\t} catch (error) {\n\t\treturn { ok: false, message: error instanceof Error ? error.message : 'Network error' }\n\t}\n\tif (!response.ok) {\n\t\treturn { ok: false, message: `Upload failed (${response.status})` }\n\t}\n\tconst data = (await response.json().catch(() => ({}))) as { doc?: { id?: string | number } }\n\tconst id = data.doc?.id\n\treturn id == null ? { ok: false, message: 'No id returned' } : { ok: true, id }\n}\n"],"mappings":";;;;;;AAeA,MAAa,aAAa,OAAO,UAAsD;CACtF,MAAM,EAAE,MAAM,YAAY,WAAW,QAAQ,YAAY,UAAU;CACnE,MAAM,OAAO,IAAI,SAAS;CAC1B,KAAK,OAAO,QAAQ,IAAI;CACxB,IAAI;CACJ,IAAI;EACH,WAAW,MAAM,UAAU,GAAG,SAAS,GAAG,cAAc;GAAE,QAAQ;GAAQ;EAAK,CAAC;CACjF,SAAS,OAAO;EACf,OAAO;GAAE,IAAI;GAAO,SAAS,iBAAiB,QAAQ,MAAM,UAAU;EAAgB;CACvF;CACA,IAAI,CAAC,SAAS,IACb,OAAO;EAAE,IAAI;EAAO,SAAS,kBAAkB,SAAS,OAAO;CAAG;CAGnE,MAAM,MAAK,MADS,SAAS,KAAK,EAAE,aAAa,CAAC,EAAE,GACpC,KAAK;CACrB,OAAO,MAAM,OAAO;EAAE,IAAI;EAAO,SAAS;CAAiB,IAAI;EAAE,IAAI;EAAM;CAAG;AAC/E"}
@@ -3,8 +3,9 @@ import { CaptchaProvider } from "./types.js";
3
3
  //#region src/spam/captcha.d.ts
4
4
  /**
5
5
  * Identity helper for authoring a captcha provider (mirrors `defineAction`/`defineConsentSource`).
6
- * v1 ships no built-in provider; a project supplies one (Turnstile/reCAPTCHA/hCaptcha) and the submit
7
- * path verifies a token carried on the submission. Prebuilt providers are a v1.x addition.
6
+ * Bundled adapters (`turnstileProvider`, `recaptchaProvider`, `hcaptchaProvider`) are built with it;
7
+ * use it directly for any other verification service. The submit path verifies a token carried on
8
+ * the submission.
8
9
  */
9
10
  declare const defineCaptchaProvider: (provider: CaptchaProvider) => CaptchaProvider;
10
11
  //#endregion
@@ -1,8 +1,9 @@
1
1
  //#region src/spam/captcha.ts
2
2
  /**
3
3
  * Identity helper for authoring a captcha provider (mirrors `defineAction`/`defineConsentSource`).
4
- * v1 ships no built-in provider; a project supplies one (Turnstile/reCAPTCHA/hCaptcha) and the submit
5
- * path verifies a token carried on the submission. Prebuilt providers are a v1.x addition.
4
+ * Bundled adapters (`turnstileProvider`, `recaptchaProvider`, `hcaptchaProvider`) are built with it;
5
+ * use it directly for any other verification service. The submit path verifies a token carried on
6
+ * the submission.
6
7
  */
7
8
  const defineCaptchaProvider = (provider) => provider;
8
9
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"captcha.js","names":[],"sources":["../../src/spam/captcha.ts"],"sourcesContent":["import type { CaptchaProvider } from './types'\n\n/**\n * Identity helper for authoring a captcha provider (mirrors `defineAction`/`defineConsentSource`).\n * v1 ships no built-in provider; a project supplies one (Turnstile/reCAPTCHA/hCaptcha) and the submit\n * path verifies a token carried on the submission. Prebuilt providers are a v1.x addition.\n */\nexport const defineCaptchaProvider = (provider: CaptchaProvider): CaptchaProvider => provider\n"],"mappings":";;;;;;AAOA,MAAa,yBAAyB,aAA+C"}
1
+ {"version":3,"file":"captcha.js","names":[],"sources":["../../src/spam/captcha.ts"],"sourcesContent":["import type { CaptchaProvider } from './types'\n\n/**\n * Identity helper for authoring a captcha provider (mirrors `defineAction`/`defineConsentSource`).\n * Bundled adapters (`turnstileProvider`, `recaptchaProvider`, `hcaptchaProvider`) are built with it;\n * use it directly for any other verification service. The submit path verifies a token carried on\n * the submission.\n */\nexport const defineCaptchaProvider = (provider: CaptchaProvider): CaptchaProvider => provider\n"],"mappings":";;;;;;;AAQA,MAAa,yBAAyB,aAA+C"}
@@ -0,0 +1,9 @@
1
+ //#region src/spam/clientIp.ts
2
+ /** First hop of a comma-separated proxy header (the client, per X-Forwarded-For convention), or undefined. */
3
+ const firstHop = (headers, header) => {
4
+ return headers?.get(header)?.split(",")[0]?.trim() || void 0;
5
+ };
6
+ //#endregion
7
+ export { firstHop };
8
+
9
+ //# sourceMappingURL=clientIp.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"clientIp.js","names":[],"sources":["../../src/spam/clientIp.ts"],"sourcesContent":["/** First hop of a comma-separated proxy header (the client, per X-Forwarded-For convention), or undefined. */\nexport const firstHop = (headers: Headers | undefined, header: string): string | undefined => {\n\tconst first = headers?.get(header)?.split(',')[0]?.trim()\n\treturn first || undefined\n}\n"],"mappings":";;AACA,MAAa,YAAY,SAA8B,WAAuC;CAE7F,OADc,SAAS,IAAI,MAAM,GAAG,MAAM,GAAG,EAAE,IAAI,KAAK,KACxC,KAAA;AACjB"}
@@ -1,3 +1,4 @@
1
+ import { firstHop } from "./clientIp.js";
1
2
  //#region src/spam/identify.ts
2
3
  /**
3
4
  * Default identity resolution for rate-limiting + upload ownership. Prefers an authenticated user id
@@ -7,12 +8,8 @@
7
8
  const defaultIdentify = (ipHeader) => (req) => {
8
9
  const userId = req.user?.id;
9
10
  if (userId != null) return `user:${String(userId)}`;
10
- const header = req.headers?.get(ipHeader);
11
- if (header) {
12
- const first = header.split(",")[0]?.trim();
13
- if (first) return `ip:${first}`;
14
- }
15
- return null;
11
+ const ip = firstHop(req.headers, ipHeader);
12
+ return ip ? `ip:${ip}` : null;
16
13
  };
17
14
  //#endregion
18
15
  export { defaultIdentify };
@@ -1 +1 @@
1
- {"version":3,"file":"identify.js","names":[],"sources":["../../src/spam/identify.ts"],"sourcesContent":["import type { IdentifyFn } from './types'\n\n/**\n * Default identity resolution for rate-limiting + upload ownership. Prefers an authenticated user id\n * (trustworthy); else the first hop of the configured trusted IP header (best-effort, proxy-dependent);\n * else null, in which case the caller fails open (cannot fairly rate-limit an unidentifiable client).\n */\nexport const defaultIdentify =\n\t(ipHeader: string): IdentifyFn =>\n\t(req) => {\n\t\tconst userId = req.user?.id\n\t\tif (userId != null) {\n\t\t\treturn `user:${String(userId)}`\n\t\t}\n\t\tconst header = req.headers?.get(ipHeader)\n\t\tif (header) {\n\t\t\tconst first = header.split(',')[0]?.trim()\n\t\t\tif (first) {\n\t\t\t\treturn `ip:${first}`\n\t\t\t}\n\t\t}\n\t\treturn null\n\t}\n"],"mappings":";;;;;;AAOA,MAAa,mBACX,cACA,QAAQ;CACR,MAAM,SAAS,IAAI,MAAM;CACzB,IAAI,UAAU,MACb,OAAO,QAAQ,OAAO,MAAM;CAE7B,MAAM,SAAS,IAAI,SAAS,IAAI,QAAQ;CACxC,IAAI,QAAQ;EACX,MAAM,QAAQ,OAAO,MAAM,GAAG,EAAE,IAAI,KAAK;EACzC,IAAI,OACH,OAAO,MAAM;CAEf;CACA,OAAO;AACR"}
1
+ {"version":3,"file":"identify.js","names":[],"sources":["../../src/spam/identify.ts"],"sourcesContent":["import { firstHop } from './clientIp'\nimport type { IdentifyFn } from './types'\n\n/**\n * Default identity resolution for rate-limiting + upload ownership. Prefers an authenticated user id\n * (trustworthy); else the first hop of the configured trusted IP header (best-effort, proxy-dependent);\n * else null, in which case the caller fails open (cannot fairly rate-limit an unidentifiable client).\n */\nexport const defaultIdentify =\n\t(ipHeader: string): IdentifyFn =>\n\t(req) => {\n\t\tconst userId = req.user?.id\n\t\tif (userId != null) {\n\t\t\treturn `user:${String(userId)}`\n\t\t}\n\t\tconst ip = firstHop(req.headers, ipHeader)\n\t\treturn ip ? `ip:${ip}` : null\n\t}\n"],"mappings":";;;;;;;AAQA,MAAa,mBACX,cACA,QAAQ;CACR,MAAM,SAAS,IAAI,MAAM;CACzB,IAAI,UAAU,MACb,OAAO,QAAQ,OAAO,MAAM;CAE7B,MAAM,KAAK,SAAS,IAAI,SAAS,QAAQ;CACzC,OAAO,KAAK,MAAM,OAAO;AAC1B"}
@@ -0,0 +1,17 @@
1
+ import { CaptchaProvider } from "../types.js";
2
+
3
+ //#region src/spam/providers/hcaptcha.d.ts
4
+ type HcaptchaProviderOptions = {
5
+ /** hCaptcha secret key (server-side, never the site key). */secretKey: string; /** Override the siteverify endpoint. */
6
+ verifyUrl?: string; /** Header read for the client IP forwarded as `remoteip`. Default 'x-forwarded-for'. */
7
+ ipHeader?: string; /** Verification request timeout in ms. Default 5000. */
8
+ timeoutMs?: number;
9
+ };
10
+ /**
11
+ * hCaptcha verification adapter. Network errors, timeouts, and non-2xx responses fail closed
12
+ * (the submission is rejected).
13
+ */
14
+ declare const hcaptchaProvider: (options: HcaptchaProviderOptions) => CaptchaProvider;
15
+ //#endregion
16
+ export { HcaptchaProviderOptions, hcaptchaProvider };
17
+ //# sourceMappingURL=hcaptcha.d.ts.map
@@ -0,0 +1,18 @@
1
+ import { defineCaptchaProvider } from "../captcha.js";
2
+ import { postSiteverify, siteverifyParams } from "./siteverify.js";
3
+ //#region src/spam/providers/hcaptcha.ts
4
+ const HCAPTCHA_VERIFY_URL = "https://api.hcaptcha.com/siteverify";
5
+ /**
6
+ * hCaptcha verification adapter. Network errors, timeouts, and non-2xx responses fail closed
7
+ * (the submission is rejected).
8
+ */
9
+ const hcaptchaProvider = (options) => defineCaptchaProvider({
10
+ type: "hcaptcha",
11
+ verify: async ({ token, req }) => {
12
+ return (await postSiteverify(options.verifyUrl ?? HCAPTCHA_VERIFY_URL, siteverifyParams(options, token, req), options.timeoutMs ?? 5e3))?.success === true;
13
+ }
14
+ });
15
+ //#endregion
16
+ export { hcaptchaProvider };
17
+
18
+ //# sourceMappingURL=hcaptcha.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hcaptcha.js","names":[],"sources":["../../../src/spam/providers/hcaptcha.ts"],"sourcesContent":["import { defineCaptchaProvider } from '../captcha'\nimport type { CaptchaProvider } from '../types'\nimport { DEFAULT_SITEVERIFY_TIMEOUT, postSiteverify, siteverifyParams } from './siteverify'\n\nconst HCAPTCHA_VERIFY_URL = 'https://api.hcaptcha.com/siteverify'\n\nexport type HcaptchaProviderOptions = {\n\t/** hCaptcha secret key (server-side, never the site key). */\n\tsecretKey: string\n\t/** Override the siteverify endpoint. */\n\tverifyUrl?: string\n\t/** Header read for the client IP forwarded as `remoteip`. Default 'x-forwarded-for'. */\n\tipHeader?: string\n\t/** Verification request timeout in ms. Default 5000. */\n\ttimeoutMs?: number\n}\n\n/**\n * hCaptcha verification adapter. Network errors, timeouts, and non-2xx responses fail closed\n * (the submission is rejected).\n */\nexport const hcaptchaProvider = (options: HcaptchaProviderOptions): CaptchaProvider =>\n\tdefineCaptchaProvider({\n\t\ttype: 'hcaptcha',\n\t\tverify: async ({ token, req }) => {\n\t\t\tconst result = await postSiteverify(\n\t\t\t\toptions.verifyUrl ?? HCAPTCHA_VERIFY_URL,\n\t\t\t\tsiteverifyParams(options, token, req),\n\t\t\t\toptions.timeoutMs ?? DEFAULT_SITEVERIFY_TIMEOUT\n\t\t\t)\n\t\t\treturn result?.success === true\n\t\t},\n\t})\n"],"mappings":";;;AAIA,MAAM,sBAAsB;;;;;AAiB5B,MAAa,oBAAoB,YAChC,sBAAsB;CACrB,MAAM;CACN,QAAQ,OAAO,EAAE,OAAO,UAAU;EAMjC,QAAO,MALc,eACpB,QAAQ,aAAa,qBACrB,iBAAiB,SAAS,OAAO,GAAG,GACpC,QAAQ,aAAA,GACT,IACe,YAAY;CAC5B;AACD,CAAC"}
@@ -0,0 +1,23 @@
1
+ import { CaptchaProvider } from "../types.js";
2
+
3
+ //#region src/spam/providers/recaptcha.d.ts
4
+ type RecaptchaProviderOptions = {
5
+ /** reCAPTCHA secret key (server-side, never the site key). */secretKey: string;
6
+ /**
7
+ * Minimum acceptable v3 score (0 to 1). Applied only when the response carries a score,
8
+ * defaulting to 0.5; v2 responses have no score and pass on `success` alone.
9
+ */
10
+ minScore?: number; /** Override the siteverify endpoint. */
11
+ verifyUrl?: string; /** Header read for the client IP forwarded as `remoteip`. Default 'x-forwarded-for'. */
12
+ ipHeader?: string; /** Verification request timeout in ms. Default 5000. */
13
+ timeoutMs?: number;
14
+ };
15
+ /**
16
+ * Google reCAPTCHA verification adapter for both v2 (checkbox) and v3 (score) tokens. A v3
17
+ * response must also meet `minScore`. Network errors, timeouts, and non-2xx responses fail
18
+ * closed (the submission is rejected).
19
+ */
20
+ declare const recaptchaProvider: (options: RecaptchaProviderOptions) => CaptchaProvider;
21
+ //#endregion
22
+ export { RecaptchaProviderOptions, recaptchaProvider };
23
+ //# sourceMappingURL=recaptcha.d.ts.map
@@ -0,0 +1,23 @@
1
+ import { defineCaptchaProvider } from "../captcha.js";
2
+ import { postSiteverify, siteverifyParams } from "./siteverify.js";
3
+ //#region src/spam/providers/recaptcha.ts
4
+ const RECAPTCHA_VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify";
5
+ const DEFAULT_MIN_SCORE = .5;
6
+ /**
7
+ * Google reCAPTCHA verification adapter for both v2 (checkbox) and v3 (score) tokens. A v3
8
+ * response must also meet `minScore`. Network errors, timeouts, and non-2xx responses fail
9
+ * closed (the submission is rejected).
10
+ */
11
+ const recaptchaProvider = (options) => defineCaptchaProvider({
12
+ type: "recaptcha",
13
+ verify: async ({ token, req }) => {
14
+ const result = await postSiteverify(options.verifyUrl ?? RECAPTCHA_VERIFY_URL, siteverifyParams(options, token, req), options.timeoutMs ?? 5e3);
15
+ if (result?.success !== true) return false;
16
+ if (typeof result.score === "number") return result.score >= (options.minScore ?? DEFAULT_MIN_SCORE);
17
+ return true;
18
+ }
19
+ });
20
+ //#endregion
21
+ export { recaptchaProvider };
22
+
23
+ //# sourceMappingURL=recaptcha.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recaptcha.js","names":[],"sources":["../../../src/spam/providers/recaptcha.ts"],"sourcesContent":["import { defineCaptchaProvider } from '../captcha'\nimport type { CaptchaProvider } from '../types'\nimport { DEFAULT_SITEVERIFY_TIMEOUT, postSiteverify, siteverifyParams } from './siteverify'\n\nconst RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify'\nconst DEFAULT_MIN_SCORE = 0.5\n\nexport type RecaptchaProviderOptions = {\n\t/** reCAPTCHA secret key (server-side, never the site key). */\n\tsecretKey: string\n\t/**\n\t * Minimum acceptable v3 score (0 to 1). Applied only when the response carries a score,\n\t * defaulting to 0.5; v2 responses have no score and pass on `success` alone.\n\t */\n\tminScore?: number\n\t/** Override the siteverify endpoint. */\n\tverifyUrl?: string\n\t/** Header read for the client IP forwarded as `remoteip`. Default 'x-forwarded-for'. */\n\tipHeader?: string\n\t/** Verification request timeout in ms. Default 5000. */\n\ttimeoutMs?: number\n}\n\n/**\n * Google reCAPTCHA verification adapter for both v2 (checkbox) and v3 (score) tokens. A v3\n * response must also meet `minScore`. Network errors, timeouts, and non-2xx responses fail\n * closed (the submission is rejected).\n */\nexport const recaptchaProvider = (options: RecaptchaProviderOptions): CaptchaProvider =>\n\tdefineCaptchaProvider({\n\t\ttype: 'recaptcha',\n\t\tverify: async ({ token, req }) => {\n\t\t\tconst result = await postSiteverify(\n\t\t\t\toptions.verifyUrl ?? RECAPTCHA_VERIFY_URL,\n\t\t\t\tsiteverifyParams(options, token, req),\n\t\t\t\toptions.timeoutMs ?? DEFAULT_SITEVERIFY_TIMEOUT\n\t\t\t)\n\t\t\tif (result?.success !== true) {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\tif (typeof result.score === 'number') {\n\t\t\t\treturn result.score >= (options.minScore ?? DEFAULT_MIN_SCORE)\n\t\t\t}\n\t\t\treturn true\n\t\t},\n\t})\n"],"mappings":";;;AAIA,MAAM,uBAAuB;AAC7B,MAAM,oBAAoB;;;;;;AAuB1B,MAAa,qBAAqB,YACjC,sBAAsB;CACrB,MAAM;CACN,QAAQ,OAAO,EAAE,OAAO,UAAU;EACjC,MAAM,SAAS,MAAM,eACpB,QAAQ,aAAa,sBACrB,iBAAiB,SAAS,OAAO,GAAG,GACpC,QAAQ,aAAA,GACT;EACA,IAAI,QAAQ,YAAY,MACvB,OAAO;EAER,IAAI,OAAO,OAAO,UAAU,UAC3B,OAAO,OAAO,UAAU,QAAQ,YAAY;EAE7C,OAAO;CACR;AACD,CAAC"}
@@ -0,0 +1,34 @@
1
+ import { firstHop } from "../clientIp.js";
2
+ //#region src/spam/providers/siteverify.ts
3
+ const DEFAULT_SITEVERIFY_TIMEOUT = 5e3;
4
+ /** Form params for a siteverify POST: secret + response, plus `remoteip` when the IP header resolves. */
5
+ const siteverifyParams = (args, token, req) => {
6
+ const params = new URLSearchParams({
7
+ secret: args.secretKey,
8
+ response: token
9
+ });
10
+ const remoteip = firstHop(req.headers, args.ipHeader ?? "x-forwarded-for");
11
+ if (remoteip) params.set("remoteip", remoteip);
12
+ return params;
13
+ };
14
+ /**
15
+ * POST a form-encoded siteverify request. Network errors, timeouts, and non-2xx responses all
16
+ * resolve to null so adapters fail closed instead of throwing into the submission path.
17
+ */
18
+ const postSiteverify = async (url, params, timeoutMs) => {
19
+ try {
20
+ const res = await fetch(url, {
21
+ method: "POST",
22
+ body: params,
23
+ signal: AbortSignal.timeout(timeoutMs)
24
+ });
25
+ if (!res.ok) return null;
26
+ return await res.json();
27
+ } catch {
28
+ return null;
29
+ }
30
+ };
31
+ //#endregion
32
+ export { DEFAULT_SITEVERIFY_TIMEOUT, postSiteverify, siteverifyParams };
33
+
34
+ //# sourceMappingURL=siteverify.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"siteverify.js","names":[],"sources":["../../../src/spam/providers/siteverify.ts"],"sourcesContent":["import type { PayloadRequest } from 'payload'\nimport { firstHop } from '../clientIp'\n\n/** The response shape shared by the Turnstile, reCAPTCHA, and hCaptcha siteverify endpoints. */\nexport type SiteverifyResult = {\n\tsuccess?: boolean\n\tscore?: number\n\t'error-codes'?: string[]\n}\n\nexport const DEFAULT_SITEVERIFY_TIMEOUT = 5_000\n\nexport type SiteverifyParamsArgs = {\n\tsecretKey: string\n\t/** Header read for the client IP forwarded as `remoteip`. Default 'x-forwarded-for'. */\n\tipHeader?: string\n}\n\n/** Form params for a siteverify POST: secret + response, plus `remoteip` when the IP header resolves. */\nexport const siteverifyParams = (\n\targs: SiteverifyParamsArgs,\n\ttoken: string,\n\treq: PayloadRequest\n): URLSearchParams => {\n\tconst params = new URLSearchParams({ secret: args.secretKey, response: token })\n\tconst remoteip = firstHop(req.headers, args.ipHeader ?? 'x-forwarded-for')\n\tif (remoteip) {\n\t\tparams.set('remoteip', remoteip)\n\t}\n\treturn params\n}\n\n/**\n * POST a form-encoded siteverify request. Network errors, timeouts, and non-2xx responses all\n * resolve to null so adapters fail closed instead of throwing into the submission path.\n */\nexport const postSiteverify = async (\n\turl: string,\n\tparams: URLSearchParams,\n\ttimeoutMs: number\n): Promise<SiteverifyResult | null> => {\n\ttry {\n\t\tconst res = await fetch(url, {\n\t\t\tmethod: 'POST',\n\t\t\tbody: params,\n\t\t\tsignal: AbortSignal.timeout(timeoutMs),\n\t\t})\n\t\tif (!res.ok) {\n\t\t\treturn null\n\t\t}\n\t\treturn (await res.json()) as SiteverifyResult\n\t} catch {\n\t\treturn null\n\t}\n}\n"],"mappings":";;AAUA,MAAa,6BAA6B;;AAS1C,MAAa,oBACZ,MACA,OACA,QACqB;CACrB,MAAM,SAAS,IAAI,gBAAgB;EAAE,QAAQ,KAAK;EAAW,UAAU;CAAM,CAAC;CAC9E,MAAM,WAAW,SAAS,IAAI,SAAS,KAAK,YAAY,iBAAiB;CACzE,IAAI,UACH,OAAO,IAAI,YAAY,QAAQ;CAEhC,OAAO;AACR;;;;;AAMA,MAAa,iBAAiB,OAC7B,KACA,QACA,cACsC;CACtC,IAAI;EACH,MAAM,MAAM,MAAM,MAAM,KAAK;GAC5B,QAAQ;GACR,MAAM;GACN,QAAQ,YAAY,QAAQ,SAAS;EACtC,CAAC;EACD,IAAI,CAAC,IAAI,IACR,OAAO;EAER,OAAQ,MAAM,IAAI,KAAK;CACxB,QAAQ;EACP,OAAO;CACR;AACD"}
@@ -0,0 +1,17 @@
1
+ import { CaptchaProvider } from "../types.js";
2
+
3
+ //#region src/spam/providers/turnstile.d.ts
4
+ type TurnstileProviderOptions = {
5
+ /** Turnstile secret key (server-side, never the site key). */secretKey: string; /** Override the siteverify endpoint. */
6
+ verifyUrl?: string; /** Header read for the client IP forwarded as `remoteip`. Default 'x-forwarded-for'. */
7
+ ipHeader?: string; /** Verification request timeout in ms. Default 5000. */
8
+ timeoutMs?: number;
9
+ };
10
+ /**
11
+ * Cloudflare Turnstile verification adapter. Network errors, timeouts, and non-2xx responses
12
+ * fail closed (the submission is rejected).
13
+ */
14
+ declare const turnstileProvider: (options: TurnstileProviderOptions) => CaptchaProvider;
15
+ //#endregion
16
+ export { TurnstileProviderOptions, turnstileProvider };
17
+ //# sourceMappingURL=turnstile.d.ts.map
@@ -0,0 +1,18 @@
1
+ import { defineCaptchaProvider } from "../captcha.js";
2
+ import { postSiteverify, siteverifyParams } from "./siteverify.js";
3
+ //#region src/spam/providers/turnstile.ts
4
+ const TURNSTILE_VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
5
+ /**
6
+ * Cloudflare Turnstile verification adapter. Network errors, timeouts, and non-2xx responses
7
+ * fail closed (the submission is rejected).
8
+ */
9
+ const turnstileProvider = (options) => defineCaptchaProvider({
10
+ type: "turnstile",
11
+ verify: async ({ token, req }) => {
12
+ return (await postSiteverify(options.verifyUrl ?? TURNSTILE_VERIFY_URL, siteverifyParams(options, token, req), options.timeoutMs ?? 5e3))?.success === true;
13
+ }
14
+ });
15
+ //#endregion
16
+ export { turnstileProvider };
17
+
18
+ //# sourceMappingURL=turnstile.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"turnstile.js","names":[],"sources":["../../../src/spam/providers/turnstile.ts"],"sourcesContent":["import { defineCaptchaProvider } from '../captcha'\nimport type { CaptchaProvider } from '../types'\nimport { DEFAULT_SITEVERIFY_TIMEOUT, postSiteverify, siteverifyParams } from './siteverify'\n\nconst TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'\n\nexport type TurnstileProviderOptions = {\n\t/** Turnstile secret key (server-side, never the site key). */\n\tsecretKey: string\n\t/** Override the siteverify endpoint. */\n\tverifyUrl?: string\n\t/** Header read for the client IP forwarded as `remoteip`. Default 'x-forwarded-for'. */\n\tipHeader?: string\n\t/** Verification request timeout in ms. Default 5000. */\n\ttimeoutMs?: number\n}\n\n/**\n * Cloudflare Turnstile verification adapter. Network errors, timeouts, and non-2xx responses\n * fail closed (the submission is rejected).\n */\nexport const turnstileProvider = (options: TurnstileProviderOptions): CaptchaProvider =>\n\tdefineCaptchaProvider({\n\t\ttype: 'turnstile',\n\t\tverify: async ({ token, req }) => {\n\t\t\tconst result = await postSiteverify(\n\t\t\t\toptions.verifyUrl ?? TURNSTILE_VERIFY_URL,\n\t\t\t\tsiteverifyParams(options, token, req),\n\t\t\t\toptions.timeoutMs ?? DEFAULT_SITEVERIFY_TIMEOUT\n\t\t\t)\n\t\t\treturn result?.success === true\n\t\t},\n\t})\n"],"mappings":";;;AAIA,MAAM,uBAAuB;;;;;AAiB7B,MAAa,qBAAqB,YACjC,sBAAsB;CACrB,MAAM;CACN,QAAQ,OAAO,EAAE,OAAO,UAAU;EAMjC,QAAO,MALc,eACpB,QAAQ,aAAa,sBACrB,iBAAiB,SAAS,OAAO,GAAG,GACpC,QAAQ,aAAA,GACT,IACe,YAAY;CAC5B;AACD,CAAC"}
@@ -1,13 +1,10 @@
1
1
  import { keys } from "../translations/keys.js";
2
2
  import { asTranslate } from "../translations/server.js";
3
+ import { firstHop } from "./clientIp.js";
3
4
  import { IDENTITY_CONTEXT_KEY } from "./constants.js";
4
5
  import { extractReservedValues, isHoneypotTripped } from "./reserved.js";
5
6
  import { APIError } from "payload";
6
7
  //#region src/spam/spamGuard.ts
7
- const firstHop = (req, header) => {
8
- const raw = req.headers?.get(header);
9
- return raw ? raw.split(",")[0]?.trim() ?? void 0 : void 0;
10
- };
11
8
  /**
12
9
  * Submissions spam guard, prepended before `validateSubmission` in `beforeValidate` so it rejects before
13
10
  * the form load + field validation. On create it: resolves the request identity once (stashing it on
@@ -49,7 +46,7 @@ const buildSpamGuard = (spam) => async ({ data, operation, req }) => {
49
46
  spam: { captcha: captchaChecked ? "passed" : "skipped" }
50
47
  };
51
48
  if (spam.metadata.ip) {
52
- const ip = firstHop(req, spam.ipHeader);
49
+ const ip = firstHop(req.headers, spam.ipHeader);
53
50
  if (ip) serverMeta.ip = ip;
54
51
  }
55
52
  if (spam.metadata.ua) {
@@ -1 +1 @@
1
- {"version":3,"file":"spamGuard.js","names":[],"sources":["../../src/spam/spamGuard.ts"],"sourcesContent":["import { APIError, type CollectionBeforeValidateHook } from 'payload'\nimport type { SubmissionValue } from '../submissions/types'\nimport { keys } from '../translations/keys'\nimport { asTranslate } from '../translations/server'\nimport { IDENTITY_CONTEXT_KEY } from './constants'\nimport { extractReservedValues, isHoneypotTripped } from './reserved'\nimport type { ResolvedSpamConfig } from './types'\n\nconst firstHop = (req: { headers?: Headers }, header: string): string | undefined => {\n\tconst raw = req.headers?.get(header)\n\treturn raw ? (raw.split(',')[0]?.trim() ?? undefined) : undefined\n}\n\n/**\n * Submissions spam guard, prepended before `validateSubmission` in `beforeValidate` so it rejects before\n * the form load + field validation. On create it: resolves the request identity once (stashing it on\n * `req.context` for upload-ownership verification downstream); rate-limits per form + identity (429,\n * fail-open when identity is null); strips reserved `values` entries (honeypot, captcha token); rejects a\n * filled honeypot with a generic error; verifies a configured captcha; and writes a server-authoritative\n * `meta` (timestamp + spam signal, with opt-in ip/ua). App-level rate limiting is defense-in-depth that\n * complements edge/CDN/WAF limiting, not a DoS replacement.\n */\nexport const buildSpamGuard =\n\t(spam: ResolvedSpamConfig): CollectionBeforeValidateHook =>\n\tasync ({ data, operation, req }) => {\n\t\tif (operation !== 'create' || !data) {\n\t\t\treturn data\n\t\t}\n\t\tconst t = asTranslate(req.i18n.t)\n\t\t// Honeypot + captcha are anti-bot measures for the anonymous public path; an authenticated request\n\t\t// (admin panel, logged-in API client) is already gated by auth and never carries a decoy/token, so\n\t\t// checking it would falsely reject legitimate authed creates. Rate-limit + ownership still apply.\n\t\tconst authenticated = Boolean(req.user)\n\n\t\tconst identity = await spam.identify(req)\n\t\tif (identity != null) {\n\t\t\treq.context[IDENTITY_CONTEXT_KEY] = identity\n\t\t}\n\n\t\tif (spam.rateLimit !== false && identity != null) {\n\t\t\tconst formKey = data.form != null ? String(data.form) : 'unknown'\n\t\t\tconst { ok } = await spam.rateLimit.limiter.check({\n\t\t\t\tkey: `submissions:${formKey}:${identity}`,\n\t\t\t\tmax: spam.rateLimit.max,\n\t\t\t\twindow: spam.rateLimit.window,\n\t\t\t\treq,\n\t\t\t})\n\t\t\tif (!ok) {\n\t\t\t\tthrow new APIError(t(keys.spamRateLimited), 429)\n\t\t\t}\n\t\t}\n\n\t\tconst honeypotField = spam.honeypot === false ? null : spam.honeypot.fieldName\n\t\tconst values = (data.values as SubmissionValue[] | undefined) ?? []\n\t\tconst { cleaned, honeypot, captchaToken } = extractReservedValues(values, honeypotField)\n\t\tdata.values = cleaned\n\n\t\tif (!authenticated && honeypotField !== null && isHoneypotTripped(honeypot)) {\n\t\t\tthrow new APIError(t(keys.spamRejected), 400)\n\t\t}\n\n\t\tconst captchaChecked = Boolean(spam.captcha) && !authenticated\n\t\tif (spam.captcha && !authenticated) {\n\t\t\tconst passed =\n\t\t\t\ttypeof captchaToken === 'string' && captchaToken.length > 0\n\t\t\t\t\t? await spam.captcha.verify({ token: captchaToken, req }).catch(() => false)\n\t\t\t\t\t: false\n\t\t\tif (!passed) {\n\t\t\t\tthrow new APIError(t(keys.spamCaptchaFailed), 400)\n\t\t\t}\n\t\t}\n\n\t\tconst serverMeta: Record<string, unknown> = {\n\t\t\tat: new Date().toISOString(),\n\t\t\tspam: { captcha: captchaChecked ? 'passed' : 'skipped' },\n\t\t}\n\t\tif (spam.metadata.ip) {\n\t\t\tconst ip = firstHop(req, spam.ipHeader)\n\t\t\tif (ip) {\n\t\t\t\tserverMeta.ip = ip\n\t\t\t}\n\t\t}\n\t\tif (spam.metadata.ua) {\n\t\t\tconst ua = req.headers?.get('user-agent')\n\t\t\tif (ua) {\n\t\t\t\tserverMeta.ua = ua\n\t\t\t}\n\t\t}\n\t\t// Preserve an authenticated (trusted) caller's own `meta` keys, with server fields winning. Anonymous\n\t\t// `meta` is never trusted, so it is discarded.\n\t\tconst clientMeta =\n\t\t\tauthenticated && data.meta != null && typeof data.meta === 'object'\n\t\t\t\t? (data.meta as Record<string, unknown>)\n\t\t\t\t: {}\n\t\tdata.meta = { ...clientMeta, ...serverMeta }\n\n\t\treturn data\n\t}\n"],"mappings":";;;;;;AAQA,MAAM,YAAY,KAA4B,WAAuC;CACpF,MAAM,MAAM,IAAI,SAAS,IAAI,MAAM;CACnC,OAAO,MAAO,IAAI,MAAM,GAAG,EAAE,IAAI,KAAK,KAAK,KAAA,IAAa,KAAA;AACzD;;;;;;;;;;AAWA,MAAa,kBACX,SACD,OAAO,EAAE,MAAM,WAAW,UAAU;CACnC,IAAI,cAAc,YAAY,CAAC,MAC9B,OAAO;CAER,MAAM,IAAI,YAAY,IAAI,KAAK,CAAC;CAIhC,MAAM,gBAAgB,QAAQ,IAAI,IAAI;CAEtC,MAAM,WAAW,MAAM,KAAK,SAAS,GAAG;CACxC,IAAI,YAAY,MACf,IAAI,QAAQ,wBAAwB;CAGrC,IAAI,KAAK,cAAc,SAAS,YAAY,MAAM;EACjD,MAAM,UAAU,KAAK,QAAQ,OAAO,OAAO,KAAK,IAAI,IAAI;EACxD,MAAM,EAAE,OAAO,MAAM,KAAK,UAAU,QAAQ,MAAM;GACjD,KAAK,eAAe,QAAQ,GAAG;GAC/B,KAAK,KAAK,UAAU;GACpB,QAAQ,KAAK,UAAU;GACvB;EACD,CAAC;EACD,IAAI,CAAC,IACJ,MAAM,IAAI,SAAS,EAAE,KAAK,eAAe,GAAG,GAAG;CAEjD;CAEA,MAAM,gBAAgB,KAAK,aAAa,QAAQ,OAAO,KAAK,SAAS;CAErE,MAAM,EAAE,SAAS,UAAU,iBAAiB,sBAD5B,KAAK,UAA4C,CAAC,GACQ,aAAa;CACvF,KAAK,SAAS;CAEd,IAAI,CAAC,iBAAiB,kBAAkB,QAAQ,kBAAkB,QAAQ,GACzE,MAAM,IAAI,SAAS,EAAE,KAAK,YAAY,GAAG,GAAG;CAG7C,MAAM,iBAAiB,QAAQ,KAAK,OAAO,KAAK,CAAC;CACjD,IAAI,KAAK,WAAW,CAAC;MAKhB,EAHH,OAAO,iBAAiB,YAAY,aAAa,SAAS,IACvD,MAAM,KAAK,QAAQ,OAAO;GAAE,OAAO;GAAc;EAAI,CAAC,EAAE,YAAY,KAAK,IACzE,QAEH,MAAM,IAAI,SAAS,EAAE,KAAK,iBAAiB,GAAG,GAAG;CAAA;CAInD,MAAM,aAAsC;EAC3C,qBAAI,IAAI,KAAK,GAAE,YAAY;EAC3B,MAAM,EAAE,SAAS,iBAAiB,WAAW,UAAU;CACxD;CACA,IAAI,KAAK,SAAS,IAAI;EACrB,MAAM,KAAK,SAAS,KAAK,KAAK,QAAQ;EACtC,IAAI,IACH,WAAW,KAAK;CAElB;CACA,IAAI,KAAK,SAAS,IAAI;EACrB,MAAM,KAAK,IAAI,SAAS,IAAI,YAAY;EACxC,IAAI,IACH,WAAW,KAAK;CAElB;CAOA,KAAK,OAAO;EAAE,GAHb,iBAAiB,KAAK,QAAQ,QAAQ,OAAO,KAAK,SAAS,WACvD,KAAK,OACN,CAAC;EACwB,GAAG;CAAW;CAE3C,OAAO;AACR"}
1
+ {"version":3,"file":"spamGuard.js","names":[],"sources":["../../src/spam/spamGuard.ts"],"sourcesContent":["import { APIError, type CollectionBeforeValidateHook } from 'payload'\nimport type { SubmissionValue } from '../submissions/types'\nimport { keys } from '../translations/keys'\nimport { asTranslate } from '../translations/server'\nimport { firstHop } from './clientIp'\nimport { IDENTITY_CONTEXT_KEY } from './constants'\nimport { extractReservedValues, isHoneypotTripped } from './reserved'\nimport type { ResolvedSpamConfig } from './types'\n\n/**\n * Submissions spam guard, prepended before `validateSubmission` in `beforeValidate` so it rejects before\n * the form load + field validation. On create it: resolves the request identity once (stashing it on\n * `req.context` for upload-ownership verification downstream); rate-limits per form + identity (429,\n * fail-open when identity is null); strips reserved `values` entries (honeypot, captcha token); rejects a\n * filled honeypot with a generic error; verifies a configured captcha; and writes a server-authoritative\n * `meta` (timestamp + spam signal, with opt-in ip/ua). App-level rate limiting is defense-in-depth that\n * complements edge/CDN/WAF limiting, not a DoS replacement.\n */\nexport const buildSpamGuard =\n\t(spam: ResolvedSpamConfig): CollectionBeforeValidateHook =>\n\tasync ({ data, operation, req }) => {\n\t\tif (operation !== 'create' || !data) {\n\t\t\treturn data\n\t\t}\n\t\tconst t = asTranslate(req.i18n.t)\n\t\t// Honeypot + captcha are anti-bot measures for the anonymous public path; an authenticated request\n\t\t// (admin panel, logged-in API client) is already gated by auth and never carries a decoy/token, so\n\t\t// checking it would falsely reject legitimate authed creates. Rate-limit + ownership still apply.\n\t\tconst authenticated = Boolean(req.user)\n\n\t\tconst identity = await spam.identify(req)\n\t\tif (identity != null) {\n\t\t\treq.context[IDENTITY_CONTEXT_KEY] = identity\n\t\t}\n\n\t\tif (spam.rateLimit !== false && identity != null) {\n\t\t\tconst formKey = data.form != null ? String(data.form) : 'unknown'\n\t\t\tconst { ok } = await spam.rateLimit.limiter.check({\n\t\t\t\tkey: `submissions:${formKey}:${identity}`,\n\t\t\t\tmax: spam.rateLimit.max,\n\t\t\t\twindow: spam.rateLimit.window,\n\t\t\t\treq,\n\t\t\t})\n\t\t\tif (!ok) {\n\t\t\t\tthrow new APIError(t(keys.spamRateLimited), 429)\n\t\t\t}\n\t\t}\n\n\t\tconst honeypotField = spam.honeypot === false ? null : spam.honeypot.fieldName\n\t\tconst values = (data.values as SubmissionValue[] | undefined) ?? []\n\t\tconst { cleaned, honeypot, captchaToken } = extractReservedValues(values, honeypotField)\n\t\tdata.values = cleaned\n\n\t\tif (!authenticated && honeypotField !== null && isHoneypotTripped(honeypot)) {\n\t\t\tthrow new APIError(t(keys.spamRejected), 400)\n\t\t}\n\n\t\tconst captchaChecked = Boolean(spam.captcha) && !authenticated\n\t\tif (spam.captcha && !authenticated) {\n\t\t\tconst passed =\n\t\t\t\ttypeof captchaToken === 'string' && captchaToken.length > 0\n\t\t\t\t\t? await spam.captcha.verify({ token: captchaToken, req }).catch(() => false)\n\t\t\t\t\t: false\n\t\t\tif (!passed) {\n\t\t\t\tthrow new APIError(t(keys.spamCaptchaFailed), 400)\n\t\t\t}\n\t\t}\n\n\t\tconst serverMeta: Record<string, unknown> = {\n\t\t\tat: new Date().toISOString(),\n\t\t\tspam: { captcha: captchaChecked ? 'passed' : 'skipped' },\n\t\t}\n\t\tif (spam.metadata.ip) {\n\t\t\tconst ip = firstHop(req.headers, spam.ipHeader)\n\t\t\tif (ip) {\n\t\t\t\tserverMeta.ip = ip\n\t\t\t}\n\t\t}\n\t\tif (spam.metadata.ua) {\n\t\t\tconst ua = req.headers?.get('user-agent')\n\t\t\tif (ua) {\n\t\t\t\tserverMeta.ua = ua\n\t\t\t}\n\t\t}\n\t\t// Preserve an authenticated (trusted) caller's own `meta` keys, with server fields winning. Anonymous\n\t\t// `meta` is never trusted, so it is discarded.\n\t\tconst clientMeta =\n\t\t\tauthenticated && data.meta != null && typeof data.meta === 'object'\n\t\t\t\t? (data.meta as Record<string, unknown>)\n\t\t\t\t: {}\n\t\tdata.meta = { ...clientMeta, ...serverMeta }\n\n\t\treturn data\n\t}\n"],"mappings":";;;;;;;;;;;;;;;;AAkBA,MAAa,kBACX,SACD,OAAO,EAAE,MAAM,WAAW,UAAU;CACnC,IAAI,cAAc,YAAY,CAAC,MAC9B,OAAO;CAER,MAAM,IAAI,YAAY,IAAI,KAAK,CAAC;CAIhC,MAAM,gBAAgB,QAAQ,IAAI,IAAI;CAEtC,MAAM,WAAW,MAAM,KAAK,SAAS,GAAG;CACxC,IAAI,YAAY,MACf,IAAI,QAAQ,wBAAwB;CAGrC,IAAI,KAAK,cAAc,SAAS,YAAY,MAAM;EACjD,MAAM,UAAU,KAAK,QAAQ,OAAO,OAAO,KAAK,IAAI,IAAI;EACxD,MAAM,EAAE,OAAO,MAAM,KAAK,UAAU,QAAQ,MAAM;GACjD,KAAK,eAAe,QAAQ,GAAG;GAC/B,KAAK,KAAK,UAAU;GACpB,QAAQ,KAAK,UAAU;GACvB;EACD,CAAC;EACD,IAAI,CAAC,IACJ,MAAM,IAAI,SAAS,EAAE,KAAK,eAAe,GAAG,GAAG;CAEjD;CAEA,MAAM,gBAAgB,KAAK,aAAa,QAAQ,OAAO,KAAK,SAAS;CAErE,MAAM,EAAE,SAAS,UAAU,iBAAiB,sBAD5B,KAAK,UAA4C,CAAC,GACQ,aAAa;CACvF,KAAK,SAAS;CAEd,IAAI,CAAC,iBAAiB,kBAAkB,QAAQ,kBAAkB,QAAQ,GACzE,MAAM,IAAI,SAAS,EAAE,KAAK,YAAY,GAAG,GAAG;CAG7C,MAAM,iBAAiB,QAAQ,KAAK,OAAO,KAAK,CAAC;CACjD,IAAI,KAAK,WAAW,CAAC;MAKhB,EAHH,OAAO,iBAAiB,YAAY,aAAa,SAAS,IACvD,MAAM,KAAK,QAAQ,OAAO;GAAE,OAAO;GAAc;EAAI,CAAC,EAAE,YAAY,KAAK,IACzE,QAEH,MAAM,IAAI,SAAS,EAAE,KAAK,iBAAiB,GAAG,GAAG;CAAA;CAInD,MAAM,aAAsC;EAC3C,qBAAI,IAAI,KAAK,GAAE,YAAY;EAC3B,MAAM,EAAE,SAAS,iBAAiB,WAAW,UAAU;CACxD;CACA,IAAI,KAAK,SAAS,IAAI;EACrB,MAAM,KAAK,SAAS,IAAI,SAAS,KAAK,QAAQ;EAC9C,IAAI,IACH,WAAW,KAAK;CAElB;CACA,IAAI,KAAK,SAAS,IAAI;EACrB,MAAM,KAAK,IAAI,SAAS,IAAI,YAAY;EACxC,IAAI,IACH,WAAW,KAAK;CAElB;CAOA,KAAK,OAAO;EAAE,GAHb,iBAAiB,KAAK,QAAQ,QAAQ,OAAO,KAAK,SAAS,WACvD,KAAK,OACN,CAAC;EACwB,GAAG;CAAW;CAE3C,OAAO;AACR"}
@@ -22,7 +22,7 @@ type CaptchaVerifyArgs = {
22
22
  token: string;
23
23
  req: PayloadRequest;
24
24
  };
25
- /** A captcha adapter. v1 ships the seam only (no built-in provider). Build with `defineCaptchaProvider`. */
25
+ /** A captcha adapter. Use a bundled provider (Turnstile/reCAPTCHA/hCaptcha) or build one with `defineCaptchaProvider`. */
26
26
  type CaptchaProvider = {
27
27
  type: string;
28
28
  verify(args: CaptchaVerifyArgs): Promise<boolean>;
@@ -41,7 +41,7 @@ type SpamConfig = {
41
41
  fieldName?: string;
42
42
  }; /** Per-identity rate limit on submission create. Default on (60s window, max 5). `false` disables. */
43
43
  rateLimit?: false | RateLimitConfig; /** Per-identity rate limit on upload create. Default on (60s window, max 20). `false` disables. */
44
- uploadRateLimit?: false | RateLimitConfig; /** A captcha provider (none by default; seam only in v1). */
44
+ uploadRateLimit?: false | RateLimitConfig; /** A captcha provider (none by default): `turnstileProvider`/`recaptchaProvider`/`hcaptchaProvider` or a custom adapter. */
45
45
  captcha?: CaptchaProvider; /** Identity resolution for rate-limiting, upload ownership, and (future) poll dedup. Default: user id, else trusted IP header. */
46
46
  identify?: IdentifyFn; /** Header read for the client IP (proxy-dependent, best-effort). Default 'x-forwarded-for'. */
47
47
  ipHeader?: string; /** Opt-in capture of client metadata onto the submission `meta`. Off by default. */