payload-dynamic-forms 0.0.0-stage → 0.1.0

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 (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +404 -2
  3. package/dist/access.d.ts +7 -0
  4. package/dist/access.js +10 -0
  5. package/dist/access.js.map +1 -0
  6. package/dist/collections/attachments.d.ts +6 -0
  7. package/dist/collections/attachments.js +67 -0
  8. package/dist/collections/attachments.js.map +1 -0
  9. package/dist/collections/blocks.d.ts +27 -0
  10. package/dist/collections/blocks.js +495 -0
  11. package/dist/collections/blocks.js.map +1 -0
  12. package/dist/collections/forms.d.ts +3 -0
  13. package/dist/collections/forms.js +553 -0
  14. package/dist/collections/forms.js.map +1 -0
  15. package/dist/collections/overrides.d.ts +7 -0
  16. package/dist/collections/overrides.js +49 -0
  17. package/dist/collections/overrides.js.map +1 -0
  18. package/dist/collections/ruleFields.d.ts +6 -0
  19. package/dist/collections/ruleFields.js +97 -0
  20. package/dist/collections/ruleFields.js.map +1 -0
  21. package/dist/collections/submissions.d.ts +7 -0
  22. package/dist/collections/submissions.js +225 -0
  23. package/dist/collections/submissions.js.map +1 -0
  24. package/dist/delivery/run.d.ts +36 -0
  25. package/dist/delivery/run.js +187 -0
  26. package/dist/delivery/run.js.map +1 -0
  27. package/dist/delivery/webhook.d.ts +26 -0
  28. package/dist/delivery/webhook.js +71 -0
  29. package/dist/delivery/webhook.js.map +1 -0
  30. package/dist/endpoints/public.d.ts +4 -0
  31. package/dist/endpoints/public.js +27 -0
  32. package/dist/endpoints/public.js.map +1 -0
  33. package/dist/endpoints/resend.d.ts +4 -0
  34. package/dist/endpoints/resend.js +58 -0
  35. package/dist/endpoints/resend.js.map +1 -0
  36. package/dist/endpoints/submit.d.ts +4 -0
  37. package/dist/endpoints/submit.js +320 -0
  38. package/dist/endpoints/submit.js.map +1 -0
  39. package/dist/exports/shared.d.ts +13 -0
  40. package/dist/exports/shared.js +13 -0
  41. package/dist/exports/shared.js.map +1 -0
  42. package/dist/formBlock.d.ts +13 -0
  43. package/dist/formBlock.js +25 -0
  44. package/dist/formBlock.js.map +1 -0
  45. package/dist/hooks/resendTick.d.ts +8 -0
  46. package/dist/hooks/resendTick.js +55 -0
  47. package/dist/hooks/resendTick.js.map +1 -0
  48. package/dist/hooks/validateForm.d.ts +8 -0
  49. package/dist/hooks/validateForm.js +251 -0
  50. package/dist/hooks/validateForm.js.map +1 -0
  51. package/dist/index.d.ts +18 -0
  52. package/dist/index.js +124 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/security/crypto.d.ts +2 -0
  55. package/dist/security/crypto.js +17 -0
  56. package/dist/security/crypto.js.map +1 -0
  57. package/dist/security/rateLimit.d.ts +13 -0
  58. package/dist/security/rateLimit.js +28 -0
  59. package/dist/security/rateLimit.js.map +1 -0
  60. package/dist/security/request.d.ts +3 -0
  61. package/dist/security/request.js +13 -0
  62. package/dist/security/request.js.map +1 -0
  63. package/dist/security/turnstile.d.ts +28 -0
  64. package/dist/security/turnstile.js +56 -0
  65. package/dist/security/turnstile.js.map +1 -0
  66. package/dist/server/forms.d.ts +73 -0
  67. package/dist/server/forms.js +215 -0
  68. package/dist/server/forms.js.map +1 -0
  69. package/dist/server/resend.d.ts +27 -0
  70. package/dist/server/resend.js +84 -0
  71. package/dist/server/resend.js.map +1 -0
  72. package/dist/shared/answers.d.ts +47 -0
  73. package/dist/shared/answers.js +282 -0
  74. package/dist/shared/answers.js.map +1 -0
  75. package/dist/shared/operators.d.ts +41 -0
  76. package/dist/shared/operators.js +54 -0
  77. package/dist/shared/operators.js.map +1 -0
  78. package/dist/shared/rules.d.ts +58 -0
  79. package/dist/shared/rules.js +324 -0
  80. package/dist/shared/rules.js.map +1 -0
  81. package/dist/shared/steps.d.ts +28 -0
  82. package/dist/shared/steps.js +109 -0
  83. package/dist/shared/steps.js.map +1 -0
  84. package/dist/shared/tokens.d.ts +25 -0
  85. package/dist/shared/tokens.js +96 -0
  86. package/dist/shared/tokens.js.map +1 -0
  87. package/dist/shared/types.d.ts +181 -0
  88. package/dist/shared/types.js +5 -0
  89. package/dist/shared/types.js.map +1 -0
  90. package/dist/shared/whatsapp.d.ts +20 -0
  91. package/dist/shared/whatsapp.js +26 -0
  92. package/dist/shared/whatsapp.js.map +1 -0
  93. package/dist/shared/words.d.ts +9 -0
  94. package/dist/shared/words.js +41 -0
  95. package/dist/shared/words.js.map +1 -0
  96. package/dist/types.d.ts +133 -0
  97. package/dist/types.js +3 -0
  98. package/dist/types.js.map +1 -0
  99. package/package.json +113 -4
@@ -0,0 +1,25 @@
1
+ /**
2
+ * A page block that picks one form. Add it to any blocks field; the app's renderer reads the
3
+ * form with `getPublicForm` or the public endpoint. It holds no words of its own.
4
+ */ export function formBlock(options = {}) {
5
+ return {
6
+ slug: options.slug || 'formBlock',
7
+ ...options.interfaceName ? {
8
+ interfaceName: options.interfaceName
9
+ } : {},
10
+ fields: [
11
+ {
12
+ name: 'form',
13
+ type: 'relationship',
14
+ relationTo: options.formsSlug || 'custom-forms',
15
+ required: true
16
+ }
17
+ ],
18
+ labels: {
19
+ plural: 'Forms',
20
+ singular: 'Form'
21
+ }
22
+ };
23
+ }
24
+
25
+ //# sourceMappingURL=formBlock.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/formBlock.ts"],"sourcesContent":["import type { Block } from 'payload'\n\nexport type FormBlockOptions = {\n /** Forms collection slug (default `custom-forms`; use the plugin's `slugs.forms` if renamed). */\n formsSlug?: string\n interfaceName?: string\n /** Block slug, default `formBlock`. */\n slug?: string\n}\n\n/**\n * A page block that picks one form. Add it to any blocks field; the app's renderer reads the\n * form with `getPublicForm` or the public endpoint. It holds no words of its own.\n */\nexport function formBlock(options: FormBlockOptions = {}): Block {\n return {\n slug: options.slug || 'formBlock',\n ...(options.interfaceName ? { interfaceName: options.interfaceName } : {}),\n fields: [\n {\n name: 'form',\n type: 'relationship',\n relationTo: (options.formsSlug || 'custom-forms') as never,\n required: true,\n },\n ],\n labels: { plural: 'Forms', singular: 'Form' },\n }\n}\n"],"names":["formBlock","options","slug","interfaceName","fields","name","type","relationTo","formsSlug","required","labels","plural","singular"],"mappings":"AAUA;;;CAGC,GACD,OAAO,SAASA,UAAUC,UAA4B,CAAC,CAAC;IACtD,OAAO;QACLC,MAAMD,QAAQC,IAAI,IAAI;QACtB,GAAID,QAAQE,aAAa,GAAG;YAAEA,eAAeF,QAAQE,aAAa;QAAC,IAAI,CAAC,CAAC;QACzEC,QAAQ;YACN;gBACEC,MAAM;gBACNC,MAAM;gBACNC,YAAaN,QAAQO,SAAS,IAAI;gBAClCC,UAAU;YACZ;SACD;QACDC,QAAQ;YAAEC,QAAQ;YAASC,UAAU;QAAO;IAC9C;AACF"}
@@ -0,0 +1,8 @@
1
+ import type { CollectionBeforeChangeHook } from 'payload';
2
+ import type { ResolvedOptions } from '../types.js';
3
+ export declare const RESEND_CONTEXT = "payloadFormsResend";
4
+ /**
5
+ * "Resend failed deliveries" in the sidebar: on save with the box ticked, run the failed outputs
6
+ * again (through `delivery.schedule` when set), add the tries to the log and save the box unticked.
7
+ */
8
+ export declare function resendTickHook(options: ResolvedOptions): CollectionBeforeChangeHook;
@@ -0,0 +1,55 @@
1
+ import { resendFailedDeliveries } from '../server/resend.js';
2
+ export const RESEND_CONTEXT = 'payloadFormsResend';
3
+ /**
4
+ * "Resend failed deliveries" in the sidebar: on save with the box ticked, run the failed outputs
5
+ * again (through `delivery.schedule` when set), add the tries to the log and save the box unticked.
6
+ */ export function resendTickHook(options) {
7
+ return async ({ context, data, operation, originalDoc, req })=>{
8
+ if (operation !== 'update' || !data?.resendFailed || context?.[RESEND_CONTEXT]) {
9
+ return data;
10
+ }
11
+ data.resendFailed = false;
12
+ const submission = {
13
+ ...originalDoc,
14
+ ...data
15
+ };
16
+ if (options.delivery?.schedule) {
17
+ const payload = req.payload;
18
+ options.delivery.schedule(async ()=>{
19
+ try {
20
+ const fresh = await payload.findByID({
21
+ id: submission.id,
22
+ collection: options.slugs.submissions,
23
+ depth: 0,
24
+ overrideAccess: true
25
+ });
26
+ const result = await resendFailedDeliveries(payload, fresh, req);
27
+ await payload.update({
28
+ id: submission.id,
29
+ collection: options.slugs.submissions,
30
+ context: {
31
+ [RESEND_CONTEXT]: true
32
+ },
33
+ data: {
34
+ deliveryLog: result.log,
35
+ status: result.status
36
+ },
37
+ overrideAccess: true
38
+ });
39
+ } catch (error) {
40
+ payload.logger.error({
41
+ err: error,
42
+ msg: 'payload-dynamic-forms: scheduled resend failed'
43
+ });
44
+ }
45
+ });
46
+ return data;
47
+ }
48
+ const result = await resendFailedDeliveries(req.payload, submission, req);
49
+ data.deliveryLog = result.log;
50
+ data.status = result.status;
51
+ return data;
52
+ };
53
+ }
54
+
55
+ //# sourceMappingURL=resendTick.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/hooks/resendTick.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\nimport type { ResolvedOptions } from '../types.js'\n\nimport { resendFailedDeliveries, type SubmissionDoc } from '../server/resend.js'\n\nexport const RESEND_CONTEXT = 'payloadFormsResend'\n\n/**\n * \"Resend failed deliveries\" in the sidebar: on save with the box ticked, run the failed outputs\n * again (through `delivery.schedule` when set), add the tries to the log and save the box unticked.\n */\nexport function resendTickHook(options: ResolvedOptions): CollectionBeforeChangeHook {\n return async ({ context, data, operation, originalDoc, req }) => {\n if (operation !== 'update' || !data?.resendFailed || context?.[RESEND_CONTEXT]) {\n return data\n }\n data.resendFailed = false\n const submission = { ...(originalDoc as SubmissionDoc), ...(data as Partial<SubmissionDoc>) } as SubmissionDoc\n if (options.delivery?.schedule) {\n const payload = req.payload\n options.delivery.schedule(async () => {\n try {\n const fresh = (await payload.findByID({\n id: submission.id,\n collection: options.slugs.submissions as never,\n depth: 0,\n overrideAccess: true,\n })) as unknown as SubmissionDoc\n const result = await resendFailedDeliveries(payload, fresh, req)\n await payload.update({\n id: submission.id,\n collection: options.slugs.submissions as never,\n context: { [RESEND_CONTEXT]: true },\n data: { deliveryLog: result.log, status: result.status } as never,\n overrideAccess: true,\n })\n } catch (error) {\n payload.logger.error({ err: error, msg: 'payload-dynamic-forms: scheduled resend failed' })\n }\n })\n return data\n }\n const result = await resendFailedDeliveries(req.payload, submission, req)\n data.deliveryLog = result.log\n data.status = result.status\n return data\n }\n}\n"],"names":["resendFailedDeliveries","RESEND_CONTEXT","resendTickHook","options","context","data","operation","originalDoc","req","resendFailed","submission","delivery","schedule","payload","fresh","findByID","id","collection","slugs","submissions","depth","overrideAccess","result","update","deliveryLog","log","status","error","logger","err","msg"],"mappings":"AAIA,SAASA,sBAAsB,QAA4B,sBAAqB;AAEhF,OAAO,MAAMC,iBAAiB,qBAAoB;AAElD;;;CAGC,GACD,OAAO,SAASC,eAAeC,OAAwB;IACrD,OAAO,OAAO,EAAEC,OAAO,EAAEC,IAAI,EAAEC,SAAS,EAAEC,WAAW,EAAEC,GAAG,EAAE;QAC1D,IAAIF,cAAc,YAAY,CAACD,MAAMI,gBAAgBL,SAAS,CAACH,eAAe,EAAE;YAC9E,OAAOI;QACT;QACAA,KAAKI,YAAY,GAAG;QACpB,MAAMC,aAAa;YAAE,GAAIH,WAAW;YAAoB,GAAIF,IAAI;QAA4B;QAC5F,IAAIF,QAAQQ,QAAQ,EAAEC,UAAU;YAC9B,MAAMC,UAAUL,IAAIK,OAAO;YAC3BV,QAAQQ,QAAQ,CAACC,QAAQ,CAAC;gBACxB,IAAI;oBACF,MAAME,QAAS,MAAMD,QAAQE,QAAQ,CAAC;wBACpCC,IAAIN,WAAWM,EAAE;wBACjBC,YAAYd,QAAQe,KAAK,CAACC,WAAW;wBACrCC,OAAO;wBACPC,gBAAgB;oBAClB;oBACA,MAAMC,SAAS,MAAMtB,uBAAuBa,SAASC,OAAON;oBAC5D,MAAMK,QAAQU,MAAM,CAAC;wBACnBP,IAAIN,WAAWM,EAAE;wBACjBC,YAAYd,QAAQe,KAAK,CAACC,WAAW;wBACrCf,SAAS;4BAAE,CAACH,eAAe,EAAE;wBAAK;wBAClCI,MAAM;4BAAEmB,aAAaF,OAAOG,GAAG;4BAAEC,QAAQJ,OAAOI,MAAM;wBAAC;wBACvDL,gBAAgB;oBAClB;gBACF,EAAE,OAAOM,OAAO;oBACdd,QAAQe,MAAM,CAACD,KAAK,CAAC;wBAAEE,KAAKF;wBAAOG,KAAK;oBAAiD;gBAC3F;YACF;YACA,OAAOzB;QACT;QACA,MAAMiB,SAAS,MAAMtB,uBAAuBQ,IAAIK,OAAO,EAAEH,YAAYF;QACrEH,KAAKmB,WAAW,GAAGF,OAAOG,GAAG;QAC7BpB,KAAKqB,MAAM,GAAGJ,OAAOI,MAAM;QAC3B,OAAOrB;IACT;AACF"}
@@ -0,0 +1,8 @@
1
+ import type { CollectionBeforeValidateHook } from 'payload';
2
+ import type { ResolvedOptions } from '../types.js';
3
+ /**
4
+ * Save checks: normalise keys to kebab-case, then refuse duplicate or reserved keys, rules and
5
+ * step targets naming missing keys, step loops, tokens in addresses, a visitor copy without an
6
+ * email field, uploads on a form that does not store, and webhooks that are not https.
7
+ */
8
+ export declare function validateFormBeforeSave(options: ResolvedOptions): CollectionBeforeValidateHook;
@@ -0,0 +1,251 @@
1
+ import { ValidationError } from 'payload';
2
+ import { isInputBlock, normalizeKey, splitRuleKey } from '../shared/rules.js';
3
+ function normalizeRuleKeys(group) {
4
+ for (const row of group?.rows || []){
5
+ if (!row || typeof row.fieldKey !== 'string') {
6
+ continue;
7
+ }
8
+ const { attribute, key } = splitRuleKey(row.fieldKey);
9
+ row.fieldKey = attribute ? `${key}.${attribute}` : key;
10
+ }
11
+ }
12
+ /** Finds a cycle in the step graph (next-step rules plus the fall-through to the next section). */ function findLoop(sections) {
13
+ const keys = sections.map((s)=>normalizeKey(s.key));
14
+ const edges = new Map();
15
+ keys.forEach((key, index)=>{
16
+ const out = new Set();
17
+ for (const rule of sections[index].nextStepRules || []){
18
+ const target = normalizeKey(rule?.targetSectionKey);
19
+ if (target && keys.includes(target)) {
20
+ out.add(target);
21
+ }
22
+ }
23
+ if (keys[index + 1]) {
24
+ out.add(keys[index + 1]);
25
+ }
26
+ edges.set(key, out);
27
+ });
28
+ const state = new Map();
29
+ const stack = [];
30
+ const visit = (key)=>{
31
+ state.set(key, 'open');
32
+ stack.push(key);
33
+ for (const next of edges.get(key) || []){
34
+ if (state.get(next) === 'open') {
35
+ return [
36
+ ...stack.slice(stack.indexOf(next)),
37
+ next
38
+ ];
39
+ }
40
+ if (!state.has(next)) {
41
+ const found = visit(next);
42
+ if (found) {
43
+ return found;
44
+ }
45
+ }
46
+ }
47
+ stack.pop();
48
+ state.set(key, 'done');
49
+ return null;
50
+ };
51
+ for (const key of keys){
52
+ if (!state.has(key)) {
53
+ const found = visit(key);
54
+ if (found) {
55
+ return found;
56
+ }
57
+ }
58
+ }
59
+ return null;
60
+ }
61
+ const TOKEN = /\{\{/;
62
+ /**
63
+ * Save checks: normalise keys to kebab-case, then refuse duplicate or reserved keys, rules and
64
+ * step targets naming missing keys, step loops, tokens in addresses, a visitor copy without an
65
+ * email field, uploads on a form that does not store, and webhooks that are not https.
66
+ */ export function validateFormBeforeSave(options) {
67
+ const reserved = [
68
+ options.honeypotField,
69
+ options.turnstile.fieldName
70
+ ].map((name)=>name.trim().toLowerCase());
71
+ return ({ collection, data, originalDoc, req })=>{
72
+ if (!data) {
73
+ return data;
74
+ }
75
+ const form = data;
76
+ const original = originalDoc || {};
77
+ const issues = [];
78
+ const sections = Array.isArray(form.sections) ? form.sections : original.sections || [];
79
+ // Keys, duplicates, reserved names.
80
+ const sectionKeys = new Set();
81
+ const fieldTypes = new Map();
82
+ sections.forEach((section, s)=>{
83
+ if (!section) {
84
+ return;
85
+ }
86
+ section.key = normalizeKey(section.key);
87
+ if (section.key && sectionKeys.has(section.key)) {
88
+ issues.push({
89
+ message: `Two sections use the key "${section.key}".`,
90
+ path: `sections.${s}.key`
91
+ });
92
+ }
93
+ sectionKeys.add(section.key || '');
94
+ (section.fields || []).forEach((field, f)=>{
95
+ if (!isInputBlock(field)) {
96
+ return;
97
+ }
98
+ const raw = typeof field.fieldKey === 'string' ? field.fieldKey.trim().toLowerCase() : '';
99
+ const key = normalizeKey(field.fieldKey);
100
+ field.fieldKey = key;
101
+ const path = `sections.${s}.fields.${f}.fieldKey`;
102
+ if (reserved.includes(raw) || reserved.includes(key)) {
103
+ issues.push({
104
+ message: `The key "${raw || key}" is reserved for the spam checks. Pick another key.`,
105
+ path
106
+ });
107
+ return;
108
+ }
109
+ if (key && fieldTypes.has(key)) {
110
+ issues.push({
111
+ message: `Two fields use the key "${key}".`,
112
+ path
113
+ });
114
+ return;
115
+ }
116
+ if (key) {
117
+ fieldTypes.set(key, field.blockType);
118
+ }
119
+ });
120
+ });
121
+ // Rules and step targets.
122
+ const checkRules = (group, path)=>{
123
+ normalizeRuleKeys(group);
124
+ (group?.rows || []).forEach((row, r)=>{
125
+ const { key } = splitRuleKey(row?.fieldKey);
126
+ if (key && !fieldTypes.has(key)) {
127
+ issues.push({
128
+ message: `A rule names the field key "${key}", which this form does not have.`,
129
+ path: `${path}.rows.${r}.fieldKey`
130
+ });
131
+ }
132
+ });
133
+ };
134
+ sections.forEach((section, s)=>{
135
+ if (!section) {
136
+ return;
137
+ }
138
+ checkRules(section.visibilityRules, `sections.${s}.visibilityRules`);
139
+ (section.fields || []).forEach((field, f)=>{
140
+ if (isInputBlock(field)) {
141
+ checkRules(field.visibilityRules, `sections.${s}.fields.${f}.visibilityRules`);
142
+ }
143
+ });
144
+ (section.nextStepRules || []).forEach((rule, r)=>{
145
+ if (!rule) {
146
+ return;
147
+ }
148
+ checkRules(rule, `sections.${s}.nextStepRules.${r}`);
149
+ rule.targetSectionKey = normalizeKey(rule.targetSectionKey);
150
+ if (rule.targetSectionKey && !sectionKeys.has(rule.targetSectionKey)) {
151
+ issues.push({
152
+ message: `A next-step rule targets the section key "${rule.targetSectionKey}", which this form does not have.`,
153
+ path: `sections.${s}.nextStepRules.${r}.targetSectionKey`
154
+ });
155
+ }
156
+ });
157
+ });
158
+ const loop = findLoop(sections.filter(Boolean));
159
+ if (loop) {
160
+ issues.push({
161
+ message: `The next-step rules make a loop: ${loop.join(' → ')}.`,
162
+ path: 'sections'
163
+ });
164
+ }
165
+ // Delivery.
166
+ const delivery = form.delivery ?? original.delivery ?? {};
167
+ const store = delivery.store !== false;
168
+ if (!store) {
169
+ for (const [key, type] of fieldTypes){
170
+ if (type === 'fileUpload') {
171
+ issues.push({
172
+ message: `The upload field "${key}" needs "Store submissions" on.`,
173
+ path: 'delivery.store'
174
+ });
175
+ }
176
+ }
177
+ }
178
+ ;
179
+ (delivery.emails || []).forEach((email, e)=>{
180
+ if (!email) {
181
+ return;
182
+ }
183
+ for (const name of [
184
+ 'to',
185
+ 'cc',
186
+ 'bcc',
187
+ 'from'
188
+ ]){
189
+ const value = email[name];
190
+ if (typeof value === 'string' && TOKEN.test(value)) {
191
+ issues.push({
192
+ message: `Email "${email.label || e + 1}": ${name} can't hold {{tokens}}. To write to the visitor, use a visitor copy instead.`,
193
+ path: `delivery.emails.${e}.${name}`
194
+ });
195
+ }
196
+ }
197
+ if (email.kind === 'visitor') {
198
+ const key = normalizeKey(email.visitorField);
199
+ email.visitorField = key;
200
+ if (fieldTypes.get(key) !== 'email') {
201
+ issues.push({
202
+ message: `Email "${email.label || e + 1}": the visitor copy needs the key of an email field ("${key}" is not one).`,
203
+ path: `delivery.emails.${e}.visitorField`
204
+ });
205
+ }
206
+ } else if (!email.to?.trim()) {
207
+ issues.push({
208
+ message: `Email "${email.label || e + 1}": add at least one address in "to".`,
209
+ path: `delivery.emails.${e}.to`
210
+ });
211
+ }
212
+ if (email.replyToField) {
213
+ const key = normalizeKey(email.replyToField);
214
+ email.replyToField = key;
215
+ if (fieldTypes.get(key) !== 'email') {
216
+ issues.push({
217
+ message: `Email "${email.label || e + 1}": reply-to needs the key of an email field ("${key}" is not one).`,
218
+ path: `delivery.emails.${e}.replyToField`
219
+ });
220
+ }
221
+ }
222
+ });
223
+ (delivery.webhooks || []).forEach((hook, w)=>{
224
+ if (!hook) {
225
+ return;
226
+ }
227
+ let ok = false;
228
+ try {
229
+ ok = new URL(String(hook.url || '')).protocol === 'https:';
230
+ } catch {
231
+ ok = false;
232
+ }
233
+ if (!ok) {
234
+ issues.push({
235
+ message: `Webhook "${hook.label || w + 1}": the URL ${hook.url || '(empty)'} must start with https://.`,
236
+ path: `delivery.webhooks.${w}.url`
237
+ });
238
+ }
239
+ });
240
+ if (issues.length) {
241
+ throw new ValidationError({
242
+ collection: collection.slug,
243
+ errors: issues,
244
+ req
245
+ });
246
+ }
247
+ return data;
248
+ };
249
+ }
250
+
251
+ //# sourceMappingURL=validateForm.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/hooks/validateForm.ts"],"sourcesContent":["import type { CollectionBeforeValidateHook } from 'payload'\n\nimport { ValidationError } from 'payload'\n\nimport type { FieldBlock, FormSection, InputBlock, RuleGroup } from '../shared/types.js'\nimport type { ResolvedOptions } from '../types.js'\n\nimport { isInputBlock, normalizeKey, splitRuleKey } from '../shared/rules.js'\n\ntype Issue = { message: string; path: string }\n\ntype EmailOutput = {\n bcc?: null | string\n cc?: null | string\n from?: null | string\n kind?: 'team' | 'visitor' | null\n label?: null | string\n replyToField?: null | string\n to?: null | string\n visitorField?: null | string\n}\n\ntype Webhook = { label?: null | string; url?: null | string }\n\ntype FormData = {\n delivery?: { emails?: EmailOutput[] | null; store?: boolean | null; webhooks?: null | Webhook[] } | null\n sections?: FormSection[] | null\n}\n\nfunction normalizeRuleKeys(group: null | RuleGroup | undefined): void {\n for (const row of group?.rows || []) {\n if (!row || typeof row.fieldKey !== 'string') {\n continue\n }\n const { attribute, key } = splitRuleKey(row.fieldKey)\n row.fieldKey = attribute ? `${key}.${attribute}` : key\n }\n}\n\n/** Finds a cycle in the step graph (next-step rules plus the fall-through to the next section). */\nfunction findLoop(sections: FormSection[]): null | string[] {\n const keys = sections.map((s) => normalizeKey(s.key))\n const edges = new Map<string, Set<string>>()\n keys.forEach((key, index) => {\n const out = new Set<string>()\n for (const rule of sections[index].nextStepRules || []) {\n const target = normalizeKey(rule?.targetSectionKey)\n if (target && keys.includes(target)) {\n out.add(target)\n }\n }\n if (keys[index + 1]) {\n out.add(keys[index + 1])\n }\n edges.set(key, out)\n })\n const state = new Map<string, 'done' | 'open'>()\n const stack: string[] = []\n const visit = (key: string): null | string[] => {\n state.set(key, 'open')\n stack.push(key)\n for (const next of edges.get(key) || []) {\n if (state.get(next) === 'open') {\n return [...stack.slice(stack.indexOf(next)), next]\n }\n if (!state.has(next)) {\n const found = visit(next)\n if (found) {\n return found\n }\n }\n }\n stack.pop()\n state.set(key, 'done')\n return null\n }\n for (const key of keys) {\n if (!state.has(key)) {\n const found = visit(key)\n if (found) {\n return found\n }\n }\n }\n return null\n}\n\nconst TOKEN = /\\{\\{/\n\n/**\n * Save checks: normalise keys to kebab-case, then refuse duplicate or reserved keys, rules and\n * step targets naming missing keys, step loops, tokens in addresses, a visitor copy without an\n * email field, uploads on a form that does not store, and webhooks that are not https.\n */\nexport function validateFormBeforeSave(options: ResolvedOptions): CollectionBeforeValidateHook {\n const reserved = [options.honeypotField, options.turnstile.fieldName].map((name) => name.trim().toLowerCase())\n\n return ({ collection, data, originalDoc, req }) => {\n if (!data) {\n return data\n }\n const form = data as FormData\n const original = (originalDoc || {}) as FormData\n const issues: Issue[] = []\n const sections = Array.isArray(form.sections) ? form.sections : original.sections || []\n\n // Keys, duplicates, reserved names.\n const sectionKeys = new Set<string>()\n const fieldTypes = new Map<string, string>()\n sections.forEach((section, s) => {\n if (!section) {\n return\n }\n section.key = normalizeKey(section.key)\n if (section.key && sectionKeys.has(section.key)) {\n issues.push({ message: `Two sections use the key \"${section.key}\".`, path: `sections.${s}.key` })\n }\n sectionKeys.add(section.key || '')\n ;(section.fields || []).forEach((field: FieldBlock, f) => {\n if (!isInputBlock(field)) {\n return\n }\n const raw = typeof field.fieldKey === 'string' ? field.fieldKey.trim().toLowerCase() : ''\n const key = normalizeKey(field.fieldKey)\n field.fieldKey = key\n const path = `sections.${s}.fields.${f}.fieldKey`\n if (reserved.includes(raw) || reserved.includes(key)) {\n issues.push({ message: `The key \"${raw || key}\" is reserved for the spam checks. Pick another key.`, path })\n return\n }\n if (key && fieldTypes.has(key)) {\n issues.push({ message: `Two fields use the key \"${key}\".`, path })\n return\n }\n if (key) {\n fieldTypes.set(key, field.blockType)\n }\n })\n })\n\n // Rules and step targets.\n const checkRules = (group: null | RuleGroup | undefined, path: string) => {\n normalizeRuleKeys(group)\n ;(group?.rows || []).forEach((row, r) => {\n const { key } = splitRuleKey(row?.fieldKey)\n if (key && !fieldTypes.has(key)) {\n issues.push({ message: `A rule names the field key \"${key}\", which this form does not have.`, path: `${path}.rows.${r}.fieldKey` })\n }\n })\n }\n sections.forEach((section, s) => {\n if (!section) {\n return\n }\n checkRules(section.visibilityRules, `sections.${s}.visibilityRules`)\n ;(section.fields || []).forEach((field: FieldBlock, f) => {\n if (isInputBlock(field)) {\n checkRules((field).visibilityRules, `sections.${s}.fields.${f}.visibilityRules`)\n }\n })\n ;(section.nextStepRules || []).forEach((rule, r) => {\n if (!rule) {\n return\n }\n checkRules(rule, `sections.${s}.nextStepRules.${r}`)\n rule.targetSectionKey = normalizeKey(rule.targetSectionKey)\n if (rule.targetSectionKey && !sectionKeys.has(rule.targetSectionKey)) {\n issues.push({\n message: `A next-step rule targets the section key \"${rule.targetSectionKey}\", which this form does not have.`,\n path: `sections.${s}.nextStepRules.${r}.targetSectionKey`,\n })\n }\n })\n })\n const loop = findLoop(sections.filter(Boolean))\n if (loop) {\n issues.push({ message: `The next-step rules make a loop: ${loop.join(' → ')}.`, path: 'sections' })\n }\n\n // Delivery.\n const delivery = form.delivery ?? original.delivery ?? {}\n const store = delivery.store !== false\n if (!store) {\n for (const [key, type] of fieldTypes) {\n if (type === 'fileUpload') {\n issues.push({ message: `The upload field \"${key}\" needs \"Store submissions\" on.`, path: 'delivery.store' })\n }\n }\n }\n ;(delivery.emails || []).forEach((email, e) => {\n if (!email) {\n return\n }\n for (const name of ['to', 'cc', 'bcc', 'from'] as const) {\n const value = email[name]\n if (typeof value === 'string' && TOKEN.test(value)) {\n issues.push({\n message: `Email \"${email.label || e + 1}\": ${name} can't hold {{tokens}}. To write to the visitor, use a visitor copy instead.`,\n path: `delivery.emails.${e}.${name}`,\n })\n }\n }\n if (email.kind === 'visitor') {\n const key = normalizeKey(email.visitorField)\n email.visitorField = key\n if (fieldTypes.get(key) !== 'email') {\n issues.push({\n message: `Email \"${email.label || e + 1}\": the visitor copy needs the key of an email field (\"${key}\" is not one).`,\n path: `delivery.emails.${e}.visitorField`,\n })\n }\n } else if (!email.to?.trim()) {\n issues.push({ message: `Email \"${email.label || e + 1}\": add at least one address in \"to\".`, path: `delivery.emails.${e}.to` })\n }\n if (email.replyToField) {\n const key = normalizeKey(email.replyToField)\n email.replyToField = key\n if (fieldTypes.get(key) !== 'email') {\n issues.push({ message: `Email \"${email.label || e + 1}\": reply-to needs the key of an email field (\"${key}\" is not one).`, path: `delivery.emails.${e}.replyToField` })\n }\n }\n })\n ;(delivery.webhooks || []).forEach((hook, w) => {\n if (!hook) {\n return\n }\n let ok = false\n try {\n ok = new URL(String(hook.url || '')).protocol === 'https:'\n } catch {\n ok = false\n }\n if (!ok) {\n issues.push({ message: `Webhook \"${hook.label || w + 1}\": the URL ${hook.url || '(empty)'} must start with https://.`, path: `delivery.webhooks.${w}.url` })\n }\n })\n\n if (issues.length) {\n throw new ValidationError({ collection: collection.slug, errors: issues, req })\n }\n return data\n }\n}\n"],"names":["ValidationError","isInputBlock","normalizeKey","splitRuleKey","normalizeRuleKeys","group","row","rows","fieldKey","attribute","key","findLoop","sections","keys","map","s","edges","Map","forEach","index","out","Set","rule","nextStepRules","target","targetSectionKey","includes","add","set","state","stack","visit","push","next","get","slice","indexOf","has","found","pop","TOKEN","validateFormBeforeSave","options","reserved","honeypotField","turnstile","fieldName","name","trim","toLowerCase","collection","data","originalDoc","req","form","original","issues","Array","isArray","sectionKeys","fieldTypes","section","message","path","fields","field","f","raw","blockType","checkRules","r","visibilityRules","loop","filter","Boolean","join","delivery","store","type","emails","email","e","value","test","label","kind","visitorField","to","replyToField","webhooks","hook","w","ok","URL","String","url","protocol","length","slug","errors"],"mappings":"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"}
@@ -0,0 +1,18 @@
1
+ import type { Config } from 'payload';
2
+ import type { FormsPluginOptions, ResolvedOptions } from './types.js';
3
+ export declare const DEFAULT_SLUGS: {
4
+ readonly attachments: "custom-form-attachments";
5
+ readonly forms: "custom-forms";
6
+ readonly submissions: "custom-form-submissions";
7
+ };
8
+ export declare function resolveOptions(options?: FormsPluginOptions): ResolvedOptions;
9
+ /**
10
+ * Adds forms, submissions and private attachments, with their endpoints, to a Payload config.
11
+ * `formsPlugin({ enabled: false })` returns the config untouched.
12
+ */
13
+ export declare const formsPlugin: (pluginOptions?: FormsPluginOptions) => (config: Config) => Config;
14
+ export { signWebhookBody } from './delivery/webhook.js';
15
+ export { formBlock, type FormBlockOptions } from './formBlock.js';
16
+ export { getPublicForm, loadPublishedForm } from './server/forms.js';
17
+ export type { Answers, AnswerValue, ChoiceOption, FieldBlock, FieldTypeChecks, FieldWords, FormSection, FormWords, InputBlock, PublicForm, RuleGroup, RuleRow, SnapshotRow, SubmissionStatus, SubmitResult, } from './shared/types.js';
18
+ export type { CollectionOverride, FieldTypeDefinition, FormsPluginOptions, OptionSource, ResolvedOptions } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,124 @@
1
+ import { attachmentLinkFields, attachmentsCollection } from './collections/attachments.js';
2
+ import { buildFieldBlocks } from './collections/blocks.js';
3
+ import { formsCollection } from './collections/forms.js';
4
+ import { applyOverrides } from './collections/overrides.js';
5
+ import { submissionsCollection } from './collections/submissions.js';
6
+ import { publicFormEndpoint } from './endpoints/public.js';
7
+ import { resendEndpoint } from './endpoints/resend.js';
8
+ import { submitEndpoint } from './endpoints/submit.js';
9
+ import { DEFAULT_FILE_LIMITS } from './shared/answers.js';
10
+ import { DEFAULT_WORDS } from './shared/words.js';
11
+ export const DEFAULT_SLUGS = {
12
+ attachments: 'custom-form-attachments',
13
+ forms: 'custom-forms',
14
+ submissions: 'custom-form-submissions'
15
+ };
16
+ export function resolveOptions(options = {}) {
17
+ const slugs = {
18
+ ...DEFAULT_SLUGS,
19
+ ...options.slugs
20
+ };
21
+ const attachments = options.attachments === false ? null : {
22
+ collection: options.attachments?.collection ? String(options.attachments.collection) : slugs.attachments,
23
+ createCollection: !options.attachments?.collection,
24
+ maxFileSize: options.attachments?.maxFileSize ?? DEFAULT_FILE_LIMITS.maxFileSize,
25
+ maxTotalSize: options.attachments?.maxTotalSize ?? DEFAULT_FILE_LIMITS.maxTotalSize,
26
+ mimeTypes: [
27
+ ...options.attachments?.mimeTypes ?? DEFAULT_FILE_LIMITS.mimeTypes
28
+ ]
29
+ };
30
+ if (attachments && !attachments.createCollection) {
31
+ slugs.attachments = attachments.collection;
32
+ }
33
+ return {
34
+ attachments,
35
+ beforeEmail: options.beforeEmail,
36
+ delivery: options.delivery,
37
+ fieldTypes: {},
38
+ honeypotField: options.honeypotField || '_hp',
39
+ isAdmin: options.access?.isAdmin || ((req)=>Boolean(req.user)),
40
+ metadata: {
41
+ ip: options.metadata?.ip === true,
42
+ userAgent: options.metadata?.userAgent !== false
43
+ },
44
+ optionSources: options.optionSources,
45
+ rateLimit: options.rateLimit === false ? null : {
46
+ max: options.rateLimit?.max ?? 5,
47
+ windowSeconds: options.rateLimit?.windowSeconds ?? 600
48
+ },
49
+ slugs,
50
+ turnstile: {
51
+ enabled: options.turnstile ? options.turnstile.enabled !== false : false,
52
+ fieldName: options.turnstile?.fieldName || 'cf-turnstile-response',
53
+ secretKey: options.turnstile?.secretKey || ''
54
+ },
55
+ whatsapp: options.whatsapp,
56
+ words: {
57
+ ...DEFAULT_WORDS,
58
+ ...options.words
59
+ }
60
+ };
61
+ }
62
+ /**
63
+ * Adds forms, submissions and private attachments, with their endpoints, to a Payload config.
64
+ * `formsPlugin({ enabled: false })` returns the config untouched.
65
+ */ export const formsPlugin = (pluginOptions = {})=>(config)=>{
66
+ if (pluginOptions.enabled === false) {
67
+ return config;
68
+ }
69
+ const options = resolveOptions(pluginOptions);
70
+ const collections = [
71
+ ...config.collections || []
72
+ ];
73
+ if (options.attachments && !options.attachments.createCollection) {
74
+ const index = collections.findIndex((c)=>c.slug === options.attachments.collection);
75
+ const target = collections[index];
76
+ if (!target || !target.upload) {
77
+ throw new Error(`formsPlugin: attachments.collection "${options.attachments.collection}" must be an upload collection in this config.`);
78
+ }
79
+ const names = new Set(target.fields.map((f)=>'name' in f ? f.name : ''));
80
+ collections[index] = {
81
+ ...target,
82
+ fields: [
83
+ ...target.fields,
84
+ ...attachmentLinkFields(options).filter((f)=>!('name' in f) || !names.has(f.name))
85
+ ]
86
+ };
87
+ }
88
+ const { blocks, fieldTypes } = buildFieldBlocks(pluginOptions.fields, {
89
+ attachmentsOn: Boolean(options.attachments),
90
+ mimeTypes: options.attachments?.mimeTypes || [
91
+ ...DEFAULT_FILE_LIMITS.mimeTypes
92
+ ],
93
+ sources: options.optionSources || {}
94
+ });
95
+ options.fieldTypes = fieldTypes;
96
+ const added = [
97
+ applyOverrides(formsCollection(options, blocks, [
98
+ submitEndpoint(options),
99
+ publicFormEndpoint(options)
100
+ ]), pluginOptions.formOverrides),
101
+ applyOverrides(submissionsCollection(options, [
102
+ resendEndpoint(options)
103
+ ]), pluginOptions.submissionOverrides)
104
+ ];
105
+ if (options.attachments?.createCollection) {
106
+ added.push(applyOverrides(attachmentsCollection(options), pluginOptions.attachmentOverrides));
107
+ }
108
+ return {
109
+ ...config,
110
+ collections: [
111
+ ...collections,
112
+ ...added
113
+ ],
114
+ custom: {
115
+ ...config.custom,
116
+ payloadForms: options
117
+ }
118
+ };
119
+ };
120
+ export { signWebhookBody } from './delivery/webhook.js';
121
+ export { formBlock } from './formBlock.js';
122
+ export { getPublicForm, loadPublishedForm } from './server/forms.js';
123
+
124
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import type { CollectionConfig, Config, PayloadRequest } from 'payload'\n\nimport type { FormsPluginOptions, ResolvedOptions } from './types.js'\n\nimport { attachmentLinkFields, attachmentsCollection } from './collections/attachments.js'\nimport { buildFieldBlocks } from './collections/blocks.js'\nimport { formsCollection } from './collections/forms.js'\nimport { applyOverrides } from './collections/overrides.js'\nimport { submissionsCollection } from './collections/submissions.js'\nimport { publicFormEndpoint } from './endpoints/public.js'\nimport { resendEndpoint } from './endpoints/resend.js'\nimport { submitEndpoint } from './endpoints/submit.js'\nimport { DEFAULT_FILE_LIMITS } from './shared/answers.js'\nimport { DEFAULT_WORDS } from './shared/words.js'\n\nexport const DEFAULT_SLUGS = {\n attachments: 'custom-form-attachments',\n forms: 'custom-forms',\n submissions: 'custom-form-submissions',\n} as const\n\nexport function resolveOptions(options: FormsPluginOptions = {}): ResolvedOptions {\n const slugs = { ...DEFAULT_SLUGS, ...options.slugs }\n const attachments =\n options.attachments === false\n ? null\n : {\n collection: options.attachments?.collection ? String(options.attachments.collection) : slugs.attachments,\n createCollection: !options.attachments?.collection,\n maxFileSize: options.attachments?.maxFileSize ?? DEFAULT_FILE_LIMITS.maxFileSize,\n maxTotalSize: options.attachments?.maxTotalSize ?? DEFAULT_FILE_LIMITS.maxTotalSize,\n mimeTypes: [...(options.attachments?.mimeTypes ?? DEFAULT_FILE_LIMITS.mimeTypes)],\n }\n if (attachments && !attachments.createCollection) {\n slugs.attachments = attachments.collection\n }\n return {\n attachments,\n beforeEmail: options.beforeEmail,\n delivery: options.delivery,\n fieldTypes: {},\n honeypotField: options.honeypotField || '_hp',\n isAdmin: options.access?.isAdmin || ((req: PayloadRequest) => Boolean(req.user)),\n metadata: { ip: options.metadata?.ip === true, userAgent: options.metadata?.userAgent !== false },\n optionSources: options.optionSources,\n rateLimit:\n options.rateLimit === false\n ? null\n : { max: options.rateLimit?.max ?? 5, windowSeconds: options.rateLimit?.windowSeconds ?? 600 },\n slugs,\n turnstile: {\n enabled: options.turnstile ? options.turnstile.enabled !== false : false,\n fieldName: options.turnstile?.fieldName || 'cf-turnstile-response',\n secretKey: options.turnstile?.secretKey || '',\n },\n whatsapp: options.whatsapp,\n words: { ...DEFAULT_WORDS, ...options.words },\n }\n}\n\n/**\n * Adds forms, submissions and private attachments, with their endpoints, to a Payload config.\n * `formsPlugin({ enabled: false })` returns the config untouched.\n */\nexport const formsPlugin =\n (pluginOptions: FormsPluginOptions = {}) =>\n (config: Config): Config => {\n if (pluginOptions.enabled === false) {\n return config\n }\n const options = resolveOptions(pluginOptions)\n const collections = [...(config.collections || [])]\n\n if (options.attachments && !options.attachments.createCollection) {\n const index = collections.findIndex((c) => c.slug === options.attachments!.collection)\n const target = collections[index]\n if (!target || !target.upload) {\n throw new Error(\n `formsPlugin: attachments.collection \"${options.attachments.collection}\" must be an upload collection in this config.`,\n )\n }\n const names = new Set(target.fields.map((f) => ('name' in f ? f.name : '')))\n collections[index] = {\n ...target,\n fields: [...target.fields, ...attachmentLinkFields(options).filter((f) => !('name' in f) || !names.has(f.name))],\n }\n }\n\n const { blocks, fieldTypes } = buildFieldBlocks(pluginOptions.fields, {\n attachmentsOn: Boolean(options.attachments),\n mimeTypes: options.attachments?.mimeTypes || [...DEFAULT_FILE_LIMITS.mimeTypes],\n sources: options.optionSources || {},\n })\n options.fieldTypes = fieldTypes\n\n const added: CollectionConfig[] = [\n applyOverrides(formsCollection(options, blocks, [submitEndpoint(options), publicFormEndpoint(options)]), pluginOptions.formOverrides),\n applyOverrides(submissionsCollection(options, [resendEndpoint(options)]), pluginOptions.submissionOverrides),\n ]\n if (options.attachments?.createCollection) {\n added.push(applyOverrides(attachmentsCollection(options), pluginOptions.attachmentOverrides))\n }\n\n return {\n ...config,\n collections: [...collections, ...added],\n custom: { ...config.custom, payloadForms: options },\n }\n }\n\nexport { signWebhookBody } from './delivery/webhook.js'\nexport { formBlock, type FormBlockOptions } from './formBlock.js'\nexport { getPublicForm, loadPublishedForm } from './server/forms.js'\nexport type {\n Answers,\n AnswerValue,\n ChoiceOption,\n FieldBlock,\n FieldTypeChecks,\n FieldWords,\n FormSection,\n FormWords,\n InputBlock,\n PublicForm,\n RuleGroup,\n RuleRow,\n SnapshotRow,\n SubmissionStatus,\n SubmitResult,\n} from './shared/types.js'\nexport type { CollectionOverride, FieldTypeDefinition, FormsPluginOptions, OptionSource, ResolvedOptions } from './types.js'\n"],"names":["attachmentLinkFields","attachmentsCollection","buildFieldBlocks","formsCollection","applyOverrides","submissionsCollection","publicFormEndpoint","resendEndpoint","submitEndpoint","DEFAULT_FILE_LIMITS","DEFAULT_WORDS","DEFAULT_SLUGS","attachments","forms","submissions","resolveOptions","options","slugs","collection","String","createCollection","maxFileSize","maxTotalSize","mimeTypes","beforeEmail","delivery","fieldTypes","honeypotField","isAdmin","access","req","Boolean","user","metadata","ip","userAgent","optionSources","rateLimit","max","windowSeconds","turnstile","enabled","fieldName","secretKey","whatsapp","words","formsPlugin","pluginOptions","config","collections","index","findIndex","c","slug","target","upload","Error","names","Set","fields","map","f","name","filter","has","blocks","attachmentsOn","sources","added","formOverrides","submissionOverrides","push","attachmentOverrides","custom","payloadForms","signWebhookBody","formBlock","getPublicForm","loadPublishedForm"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export declare function sha256Hex(text: string): Promise<string>;
2
+ export declare function hmacSha256Hex(secret: string, text: string): Promise<string>;
@@ -0,0 +1,17 @@
1
+ /** Web Crypto only (works on Workers and Node); never node:crypto. */ function toHex(buffer) {
2
+ return Array.from(new Uint8Array(buffer)).map((b)=>b.toString(16).padStart(2, '0')).join('');
3
+ }
4
+ export async function sha256Hex(text) {
5
+ return toHex(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)));
6
+ }
7
+ export async function hmacSha256Hex(secret, text) {
8
+ const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), {
9
+ name: 'HMAC',
10
+ hash: 'SHA-256'
11
+ }, false, [
12
+ 'sign'
13
+ ]);
14
+ return toHex(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(text)));
15
+ }
16
+
17
+ //# sourceMappingURL=crypto.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/security/crypto.ts"],"sourcesContent":["/** Web Crypto only (works on Workers and Node); never node:crypto. */\nfunction toHex(buffer: ArrayBuffer): string {\n return Array.from(new Uint8Array(buffer))\n .map((b) => b.toString(16).padStart(2, '0'))\n .join('')\n}\n\nexport async function sha256Hex(text: string): Promise<string> {\n return toHex(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))\n}\n\nexport async function hmacSha256Hex(secret: string, text: string): Promise<string> {\n const key = await crypto.subtle.importKey(\n 'raw',\n new TextEncoder().encode(secret),\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign'],\n )\n return toHex(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(text)))\n}\n"],"names":["toHex","buffer","Array","from","Uint8Array","map","b","toString","padStart","join","sha256Hex","text","crypto","subtle","digest","TextEncoder","encode","hmacSha256Hex","secret","key","importKey","name","hash","sign"],"mappings":"AAAA,oEAAoE,GACpE,SAASA,MAAMC,MAAmB;IAChC,OAAOC,MAAMC,IAAI,CAAC,IAAIC,WAAWH,SAC9BI,GAAG,CAAC,CAACC,IAAMA,EAAEC,QAAQ,CAAC,IAAIC,QAAQ,CAAC,GAAG,MACtCC,IAAI,CAAC;AACV;AAEA,OAAO,eAAeC,UAAUC,IAAY;IAC1C,OAAOX,MAAM,MAAMY,OAAOC,MAAM,CAACC,MAAM,CAAC,WAAW,IAAIC,cAAcC,MAAM,CAACL;AAC9E;AAEA,OAAO,eAAeM,cAAcC,MAAc,EAAEP,IAAY;IAC9D,MAAMQ,MAAM,MAAMP,OAAOC,MAAM,CAACO,SAAS,CACvC,OACA,IAAIL,cAAcC,MAAM,CAACE,SACzB;QAAEG,MAAM;QAAQC,MAAM;IAAU,GAChC,OACA;QAAC;KAAO;IAEV,OAAOtB,MAAM,MAAMY,OAAOC,MAAM,CAACU,IAAI,CAAC,QAAQJ,KAAK,IAAIJ,cAAcC,MAAM,CAACL;AAC9E"}
@@ -0,0 +1,13 @@
1
+ import type { Payload } from 'payload';
2
+ /**
3
+ * Soft per-form, per-visitor limit in Payload's KV store. The key holds a hash of the IP and the
4
+ * app secret, never the IP. Returns false when the visitor is over the limit.
5
+ */
6
+ export declare function checkRateLimit(args: {
7
+ formId: number | string;
8
+ ip: string;
9
+ max: number;
10
+ now?: number;
11
+ payload: Payload;
12
+ windowSeconds: number;
13
+ }): Promise<boolean>;
@@ -0,0 +1,28 @@
1
+ import { sha256Hex } from './crypto.js';
2
+ /**
3
+ * Soft per-form, per-visitor limit in Payload's KV store. The key holds a hash of the IP and the
4
+ * app secret, never the IP. Returns false when the visitor is over the limit.
5
+ */ export async function checkRateLimit(args) {
6
+ const { payload } = args;
7
+ const now = args.now ?? Date.now();
8
+ const hash = await sha256Hex(`${args.ip || 'unknown'}${payload.secret}`);
9
+ const key = `forms:rl:${args.formId}:${hash}`;
10
+ const entry = await payload.kv.get(key);
11
+ if (!entry || typeof entry.start !== 'number' || now - entry.start >= args.windowSeconds * 1000) {
12
+ await payload.kv.set(key, {
13
+ count: 1,
14
+ start: now
15
+ });
16
+ return true;
17
+ }
18
+ if (entry.count >= args.max) {
19
+ return false;
20
+ }
21
+ await payload.kv.set(key, {
22
+ count: entry.count + 1,
23
+ start: entry.start
24
+ });
25
+ return true;
26
+ }
27
+
28
+ //# sourceMappingURL=rateLimit.js.map