payload-dynamic-forms 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -62,7 +62,8 @@ Every option is optional.
62
62
  | `whatsapp.number` | – | `({ req, form }) => string` for forms without their own number. |
63
63
  | `delivery.schedule` | – | Runs email and webhooks after the reply (see [Delivery after the reply](#delivery-after-the-reply)). |
64
64
  | `beforeEmail` | – | `(message, { form, answers, req }) => message`: wrap the html in your layout, add headers. |
65
- | `access.isAdmin` | any logged-in user | Who reads and writes webhook secrets, updates or deletes submissions and resends. |
65
+ | `webhooks` | admin secrets on, signing optional | `{ signingSecret?, headers?, secretsInAdmin?, requireSignature? }`: webhook secrets and headers from your config (values or `({ form, req, webhook }) => …`), whether the admin has secret fields, and whether unsigned webhooks are refused (see [Webhook signature](#webhook-signature)). |
66
+ | `access.isAdmin` | any logged-in user | Who reads and writes webhook secrets in the admin, updates or deletes submissions and resends. Prefer secrets from your config (`webhooks.signingSecret` from an env var, with `webhooks.secretsInAdmin: false`): admin secrets are stored as plain text in your database and its backups. |
66
67
  | `metadata` | `{ ip: false, userAgent: true }` | What a submission keeps about the request. |
67
68
 
68
69
  ### Field types
@@ -107,6 +108,8 @@ To use an upload collection of your own instead, pass `attachments: { collection
107
108
 
108
109
  The plugin adds collections, so apps on Postgres or SQLite/D1 with migrations run `payload migrate:create` after installing it (and after changing `slugs`, `fields` or `optionSources`), then `payload migrate`. Default slugs differ from the official form builder's `forms` and `form-submissions`, so both can run while you move over.
109
110
 
111
+ Setting `webhooks.secretsInAdmin: false` also changes the schema: see [Secrets from your config](#secrets-from-your-config).
112
+
110
113
  ## Switching off
111
114
 
112
115
  `formsPlugin({ enabled: false })` returns your config unchanged: no collections, fields, endpoints or hooks. A starter can carry the call switched off.
@@ -352,7 +355,31 @@ Each email or webhook try of a stored submission adds a log entry (kind, output,
352
355
 
353
356
  ## Webhook signature
354
357
 
355
- Webhooks send JSON (full: form, submission id, snapshot, attachments and answers; or answers only) by POST, PUT or PATCH with their secret headers, a timeout (default 8 s) and up to 3 retries (pauses 0.5 s, 1 s, 2 s). With a signing secret they carry `X-Form-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">`. Check it on the receiving side:
358
+ Webhooks send JSON (full: form, submission id, snapshot, attachments and answers; or answers only) by POST, PUT or PATCH with their headers, a timeout (default 8 s) and up to 3 retries (pauses 0.5 s, 1 s, 2 s).
359
+
360
+ ### Secrets from your config
361
+
362
+ A webhook's signing secret and secret headers can come from two places: the admin-only fields on each webhook row, or the plugin config. Keep them in env vars when you can:
363
+
364
+ ```ts
365
+ formsPlugin({
366
+ webhooks: {
367
+ // A string, or a function of { form, req, webhook: { id, label, url } } (may be async).
368
+ signingSecret: ({ webhook }) => (webhook.label === 'CRM' ? process.env.CRM_WEBHOOK_SECRET : process.env.FORMS_WEBHOOK_SECRET),
369
+ headers: { 'X-Api-Key': process.env.CRM_API_KEY! },
370
+ secretsInAdmin: false,
371
+ requireSignature: true,
372
+ },
373
+ })
374
+ ```
375
+
376
+ - **Which secret wins:** the webhook's own admin `signingSecret`, when it is not empty, wins over `webhooks.signingSecret`; the config secret is the fallback. An empty string or `undefined` from either means no secret. So an old admin secret left on a form keeps signing even after you set a config one: clear it, or turn the fields off.
377
+ - **Header order:** `Content-Type: application/json`, then `webhooks.headers`, then the webhook's admin secret headers, then the signature headers. A later header replaces an earlier one of the same name (any case), so admin headers override config ones and nothing overrides `X-Form-Signature` or the `webhook-*` headers.
378
+ - **`secretsInAdmin: false`** leaves the signing secret and secret header fields out of the forms collection (not hidden: gone), so no secret is stored in your database. This changes the schema: your next migration drops the signing-secret column and the webhook headers table, and **any secrets stored there are lost**. Move them to your config first.
379
+
380
+ ### Checking `X-Form-Signature`
381
+
382
+ A signed webhook carries `X-Form-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">`, keyed by the whole secret string, as in 0.1.0. Check it on the receiving side:
356
383
 
357
384
  ```ts
358
385
  import { createHmac, timingSafeEqual } from 'node:crypto'
@@ -366,6 +393,36 @@ export function verifyFormSignature(header: string, rawBody: string, secret: str
366
393
  }
367
394
  ```
368
395
 
396
+ ### Standard Webhooks
397
+
398
+ Webhooks also carry the [Standard Webhooks](https://www.standardwebhooks.com/) headers next to `X-Form-Signature`, so receivers can use a ready-made verifier:
399
+
400
+ - `webhook-id` on every webhook, signed or not: `msg_<submissionId>_<outputId>` for a stored submission, else `msg_` plus a random id. It is the same on every retry and on every resend of a stored submission, so use it as an idempotency key to skip a delivery you already handled.
401
+ - `webhook-timestamp` (unix seconds, the same `t` as in `X-Form-Signature`) and `webhook-signature: v1,<base64 HMAC-SHA256 of "<id>.<timestamp>.<raw body>">` on signed webhooks.
402
+
403
+ The HMAC key is the decoded bytes of a `whsec_` secret, or the secret's UTF-8 bytes otherwise. To verify with the [`standardwebhooks`](https://www.npmjs.com/package/standardwebhooks) library, use a `whsec_` secret (`'whsec_' + base64 of 32 random bytes`):
404
+
405
+ ```sh
406
+ node -e "console.log('whsec_' + require('crypto').randomBytes(32).toString('base64'))"
407
+ ```
408
+
409
+ ```ts
410
+ import { Webhook } from 'standardwebhooks'
411
+
412
+ // Throws when the signature or timestamp doesn't check out; returns the parsed body.
413
+ const payload = new Webhook(process.env.FORMS_WEBHOOK_SECRET!).verify(rawBody, {
414
+ 'webhook-id': request.headers.get('webhook-id')!,
415
+ 'webhook-timestamp': request.headers.get('webhook-timestamp')!,
416
+ 'webhook-signature': request.headers.get('webhook-signature')!,
417
+ })
418
+ ```
419
+
420
+ ### Unsigned webhooks
421
+
422
+ A webhook with no secret from either place is sent unsigned, and the server logs one warning per webhook per process: `payload-dynamic-forms: webhook "<label>" on form "<slug>" is sent unsigned; set a signing secret.` With `webhooks.requireSignature: true` it isn't sent at all: its delivery log entry is `failed` with 0 tries and the error `Webhook not sent: no signing secret.`, and the status and resend follow the usual rules.
423
+
424
+ New in 0.2.0: receivers see the `webhook-*` headers (and `webhook-id` on unsigned webhooks too). A receiver that refuses unknown headers needs to allow them.
425
+
369
426
  ## CSV export
370
427
 
371
428
  Use Payload's import-export plugin for the submissions collection (no jobs runner needed in sync mode):
@@ -32,6 +32,58 @@ function wordingField(options) {
32
32
  label: 'Form wording'
33
33
  };
34
34
  }
35
+ /** The webhook row's admin-only secret fields; left out with `webhooks.secretsInAdmin: false`. */ function secretFields(secret) {
36
+ return [
37
+ {
38
+ name: 'headers',
39
+ type: 'array',
40
+ access: {
41
+ create: secret,
42
+ read: secret,
43
+ update: secret
44
+ },
45
+ admin: {
46
+ description: 'Secret headers (admins only).',
47
+ initCollapsed: true
48
+ },
49
+ fields: [
50
+ {
51
+ type: 'row',
52
+ fields: [
53
+ {
54
+ name: 'key',
55
+ type: 'text',
56
+ admin: {
57
+ width: '50%'
58
+ },
59
+ required: true
60
+ },
61
+ {
62
+ name: 'value',
63
+ type: 'text',
64
+ admin: {
65
+ width: '50%'
66
+ },
67
+ required: true
68
+ }
69
+ ]
70
+ }
71
+ ]
72
+ },
73
+ {
74
+ name: 'signingSecret',
75
+ type: 'text',
76
+ access: {
77
+ create: secret,
78
+ read: secret,
79
+ update: secret
80
+ },
81
+ admin: {
82
+ description: 'Signs the body: X-Form-Signature: t=<seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">, plus the Standard Webhooks headers. Empty: the plugin config\'s secret, if any (admins only).'
83
+ }
84
+ }
85
+ ];
86
+ }
35
87
  function deliveryField(options) {
36
88
  const secret = adminFieldAccess(options.isAdmin);
37
89
  return {
@@ -262,54 +314,7 @@ function deliveryField(options) {
262
314
  }
263
315
  ]
264
316
  },
265
- {
266
- name: 'headers',
267
- type: 'array',
268
- access: {
269
- create: secret,
270
- read: secret,
271
- update: secret
272
- },
273
- admin: {
274
- description: 'Secret headers (admins only).',
275
- initCollapsed: true
276
- },
277
- fields: [
278
- {
279
- type: 'row',
280
- fields: [
281
- {
282
- name: 'key',
283
- type: 'text',
284
- admin: {
285
- width: '50%'
286
- },
287
- required: true
288
- },
289
- {
290
- name: 'value',
291
- type: 'text',
292
- admin: {
293
- width: '50%'
294
- },
295
- required: true
296
- }
297
- ]
298
- }
299
- ]
300
- },
301
- {
302
- name: 'signingSecret',
303
- type: 'text',
304
- access: {
305
- create: secret,
306
- read: secret,
307
- update: secret
308
- },
309
- admin: {
310
- description: 'Signs the body: X-Form-Signature: t=<seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>"> (admins only).'
311
- }
312
- }
317
+ ...options.webhooks.secretsInAdmin ? secretFields(secret) : []
313
318
  ]
314
319
  },
315
320
  {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/collections/forms.ts"],"sourcesContent":["import type { Block, CollectionConfig, Endpoint, Field } from 'payload'\n\nimport { slugField } from 'payload'\n\nimport type { ResolvedOptions } from '../types.js'\n\nimport { adminFieldAccess, loggedIn } from '../access.js'\nimport { validateFormBeforeSave } from '../hooks/validateForm.js'\nimport { DEFAULT_WHATSAPP_ROW_TEMPLATE, DEFAULT_WHATSAPP_TEMPLATE } from '../shared/whatsapp.js'\nimport { WORD_KEYS } from '../shared/words.js'\nimport { nextStepRulesField, ruleGroupField } from './ruleFields.js'\n\nconst WORD_HELP: Record<string, string> = {\n errorPrefix: 'Shown above the field errors.',\n fileRejected: 'Field error for a file that is refused. {{label}} is the field label.',\n invalid: 'Field error for a bad value. {{label}} is the field label.',\n no: 'An unticked checkbox in emails, WhatsApp messages, webhooks and the stored snapshot.',\n required: 'Field error for a missing answer. {{label}} is the field label.',\n stepProgress: '{{current}} and {{total}} are the step numbers.',\n yes: 'A ticked checkbox in emails, WhatsApp messages, webhooks and the stored snapshot.',\n}\n\nfunction wordingField(options: ResolvedOptions): Field {\n return {\n name: 'wording',\n type: 'group',\n admin: { description: 'Every word visitors read besides the questions. New forms start with the defaults.' },\n fields: WORD_KEYS.map(\n (key): Field =>\n ({\n name: key,\n type: key === 'success' || key === 'closed' ? 'textarea' : 'text',\n admin: WORD_HELP[key] ? { description: WORD_HELP[key] } : undefined,\n defaultValue: options.words[key],\n localized: true,\n }) as Field,\n ),\n label: 'Form wording',\n }\n}\n\nfunction deliveryField(options: ResolvedOptions): Field {\n const secret = adminFieldAccess(options.isAdmin)\n return {\n name: 'delivery',\n type: 'group',\n access: { read: loggedIn },\n fields: [\n {\n name: 'store',\n type: 'checkbox',\n admin: { description: 'Save each submission. Off: nothing is written; email, webhooks and WhatsApp still run.' },\n defaultValue: true,\n label: 'Store submissions',\n },\n {\n name: 'emails',\n type: 'array',\n admin: { initCollapsed: true },\n fields: [\n {\n type: 'row',\n fields: [\n { name: 'label', type: 'text', admin: { width: '50%' }, required: true },\n {\n name: 'kind',\n type: 'select',\n admin: { width: '30%' },\n dbName: 'k',\n defaultValue: 'team',\n options: [\n { label: 'Team (fixed addresses)', value: 'team' },\n { label: 'Visitor copy', value: 'visitor' },\n ],\n },\n { name: 'enabled', type: 'checkbox', admin: { width: '20%' }, defaultValue: true },\n ],\n },\n {\n name: 'to',\n type: 'text',\n admin: {\n condition: (_d, sibling) => sibling?.kind !== 'visitor',\n description: 'Fixed addresses, separated by commas. No {{tokens}}: use a visitor copy instead.',\n },\n },\n {\n name: 'visitorField',\n type: 'text',\n admin: {\n condition: (_d, sibling) => sibling?.kind === 'visitor',\n description: 'Key of the email field whose answer gets the copy.',\n },\n },\n {\n type: 'row',\n fields: [\n { name: 'cc', type: 'text', admin: { width: '50%' } },\n { name: 'bcc', type: 'text', admin: { width: '50%' } },\n ],\n },\n {\n type: 'row',\n fields: [\n { name: 'from', type: 'text', admin: { description: \"Empty: the email adapter's default.\", width: '50%' } },\n { name: 'replyToField', type: 'text', admin: { description: 'Key of an email field to reply to.', width: '50%' } },\n ],\n },\n { name: 'subject', type: 'text', defaultValue: 'New submission: {{formTitle}}' },\n {\n name: 'body',\n type: 'textarea',\n admin: { description: 'Tokens: {{key}}, {{key.attribute}}, {{*}}, {{*:table}}, {{formTitle}}, {{submissionId}}.' },\n defaultValue: '{{*:table}}',\n },\n ],\n },\n {\n name: 'webhooks',\n type: 'array',\n admin: { initCollapsed: true },\n fields: [\n {\n type: 'row',\n fields: [\n { name: 'label', type: 'text', admin: { width: '50%' }, required: true },\n {\n name: 'method',\n type: 'select',\n admin: { width: '30%' },\n dbName: 'mth',\n defaultValue: 'POST',\n options: ['POST', 'PUT', 'PATCH'],\n },\n { name: 'enabled', type: 'checkbox', admin: { width: '20%' }, defaultValue: true },\n ],\n },\n { name: 'url', type: 'text', admin: { description: 'https only.' }, required: true },\n {\n type: 'row',\n fields: [\n {\n name: 'payloadMode',\n type: 'select',\n admin: { width: '40%' },\n dbName: 'pmd',\n defaultValue: 'full',\n options: [\n { label: 'Full (form, submission, answers)', value: 'full' },\n { label: 'Answers only', value: 'answersOnly' },\n ],\n },\n { name: 'timeoutMs', type: 'number', admin: { width: '30%' }, defaultValue: 8000, max: 30000, min: 500 },\n { name: 'retries', type: 'number', admin: { width: '30%' }, defaultValue: 0, max: 3, min: 0 },\n ],\n },\n {\n name: 'headers',\n type: 'array',\n access: { create: secret, read: secret, update: secret },\n admin: { description: 'Secret headers (admins only).', initCollapsed: true },\n fields: [\n {\n type: 'row',\n fields: [\n { name: 'key', type: 'text', admin: { width: '50%' }, required: true },\n { name: 'value', type: 'text', admin: { width: '50%' }, required: true },\n ],\n },\n ],\n },\n {\n name: 'signingSecret',\n type: 'text',\n access: { create: secret, read: secret, update: secret },\n admin: { description: 'Signs the body: X-Form-Signature: t=<seconds>,v1=<hex HMAC-SHA256 of \"<t>.<body>\"> (admins only).' },\n },\n ],\n },\n {\n name: 'whatsapp',\n type: 'group',\n fields: [\n { name: 'enabled', type: 'checkbox', defaultValue: false, label: 'Build a WhatsApp link' },\n {\n name: 'number',\n type: 'text',\n admin: { condition: (_d, sibling) => Boolean(sibling?.enabled), description: 'Empty: the app\\'s number.' },\n },\n {\n name: 'messageTemplate',\n type: 'textarea',\n admin: { condition: (_d, sibling) => Boolean(sibling?.enabled) },\n defaultValue: DEFAULT_WHATSAPP_TEMPLATE,\n localized: true,\n },\n {\n name: 'rowTemplate',\n type: 'text',\n admin: { condition: (_d, sibling) => Boolean(sibling?.enabled) },\n defaultValue: DEFAULT_WHATSAPP_ROW_TEMPLATE,\n localized: true,\n },\n ],\n label: 'WhatsApp',\n },\n ],\n label: 'Delivery',\n }\n}\n\nexport function formsCollection(options: ResolvedOptions, blocks: Block[], endpoints: Endpoint[]): CollectionConfig {\n return {\n slug: options.slugs.forms,\n access: {\n create: loggedIn,\n delete: loggedIn,\n read: ({ req }) => (req.user ? true : { _status: { equals: 'published' } }),\n update: loggedIn,\n },\n admin: {\n defaultColumns: ['title', 'slug', 'intakeState', '_status', 'updatedAt'],\n group: 'Forms',\n useAsTitle: 'title',\n },\n defaultPopulate: {\n slug: true,\n intakeState: true,\n presentationMode: true,\n sections: true,\n successRedirectURL: true,\n successType: true,\n title: true,\n wording: true,\n },\n endpoints,\n fields: [\n { name: 'title', type: 'text', localized: true, required: true },\n slugField({ useAsSlug: 'title' }),\n {\n type: 'tabs',\n tabs: [\n {\n fields: [\n {\n type: 'row',\n fields: [\n {\n name: 'presentationMode',\n type: 'select',\n admin: { width: '50%' },\n dbName: 'pm',\n defaultValue: 'singlePage',\n options: [\n { label: 'Single page', value: 'singlePage' },\n { label: 'Step by step', value: 'steps' },\n ],\n required: true,\n },\n {\n name: 'intakeState',\n type: 'select',\n admin: { width: '50%' },\n dbName: 'ist',\n defaultValue: 'open',\n options: [\n { label: 'Open', value: 'open' },\n { label: 'Closed', value: 'closed' },\n ],\n required: true,\n },\n ],\n },\n {\n name: 'sections',\n type: 'array',\n admin: { initCollapsed: true },\n fields: [\n {\n type: 'row',\n fields: [\n { name: 'key', type: 'text', admin: { width: '40%' }, required: true },\n { name: 'title', type: 'text', admin: { width: '60%' }, localized: true },\n ],\n },\n { name: 'description', type: 'textarea', localized: true },\n {\n name: 'fields',\n type: 'blocks',\n admin: { initCollapsed: true },\n blocks,\n },\n ruleGroupField('visibilityRules', 'Show section when'),\n nextStepRulesField(),\n ],\n minRows: 1,\n required: true,\n },\n ],\n label: 'Structure',\n },\n {\n fields: [\n {\n name: 'successType',\n type: 'radio',\n dbName: 'st',\n defaultValue: 'message',\n options: [\n { label: 'Show the success words', value: 'message' },\n { label: 'Redirect', value: 'redirect' },\n ],\n },\n {\n name: 'successRedirectURL',\n type: 'text',\n admin: { condition: (_d, sibling) => sibling?.successType === 'redirect' },\n },\n wordingField(options),\n ],\n label: 'Words',\n },\n { fields: [deliveryField(options)], label: 'Delivery' },\n ],\n },\n ],\n hooks: {\n beforeValidate: [validateFormBeforeSave(options)],\n },\n versions: {\n drafts: true,\n maxPerDoc: 20,\n },\n }\n}\n"],"names":["slugField","adminFieldAccess","loggedIn","validateFormBeforeSave","DEFAULT_WHATSAPP_ROW_TEMPLATE","DEFAULT_WHATSAPP_TEMPLATE","WORD_KEYS","nextStepRulesField","ruleGroupField","WORD_HELP","errorPrefix","fileRejected","invalid","no","required","stepProgress","yes","wordingField","options","name","type","admin","description","fields","map","key","undefined","defaultValue","words","localized","label","deliveryField","secret","isAdmin","access","read","initCollapsed","width","dbName","value","condition","_d","sibling","kind","max","min","create","update","Boolean","enabled","formsCollection","blocks","endpoints","slug","slugs","forms","delete","req","user","_status","equals","defaultColumns","group","useAsTitle","defaultPopulate","intakeState","presentationMode","sections","successRedirectURL","successType","title","wording","useAsSlug","tabs","minRows","hooks","beforeValidate","versions","drafts","maxPerDoc"],"mappings":"AAEA,SAASA,SAAS,QAAQ,UAAS;AAInC,SAASC,gBAAgB,EAAEC,QAAQ,QAAQ,eAAc;AACzD,SAASC,sBAAsB,QAAQ,2BAA0B;AACjE,SAASC,6BAA6B,EAAEC,yBAAyB,QAAQ,wBAAuB;AAChG,SAASC,SAAS,QAAQ,qBAAoB;AAC9C,SAASC,kBAAkB,EAAEC,cAAc,QAAQ,kBAAiB;AAEpE,MAAMC,YAAoC;IACxCC,aAAa;IACbC,cAAc;IACdC,SAAS;IACTC,IAAI;IACJC,UAAU;IACVC,cAAc;IACdC,KAAK;AACP;AAEA,SAASC,aAAaC,OAAwB;IAC5C,OAAO;QACLC,MAAM;QACNC,MAAM;QACNC,OAAO;YAAEC,aAAa;QAAqF;QAC3GC,QAAQjB,UAAUkB,GAAG,CACnB,CAACC,MACE,CAAA;gBACCN,MAAMM;gBACNL,MAAMK,QAAQ,aAAaA,QAAQ,WAAW,aAAa;gBAC3DJ,OAAOZ,SAAS,CAACgB,IAAI,GAAG;oBAAEH,aAAab,SAAS,CAACgB,IAAI;gBAAC,IAAIC;gBAC1DC,cAAcT,QAAQU,KAAK,CAACH,IAAI;gBAChCI,WAAW;YACb,CAAA;QAEJC,OAAO;IACT;AACF;AAEA,SAASC,cAAcb,OAAwB;IAC7C,MAAMc,SAAS/B,iBAAiBiB,QAAQe,OAAO;IAC/C,OAAO;QACLd,MAAM;QACNC,MAAM;QACNc,QAAQ;YAAEC,MAAMjC;QAAS;QACzBqB,QAAQ;YACN;gBACEJ,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEC,aAAa;gBAAyF;gBAC/GK,cAAc;gBACdG,OAAO;YACT;YACA;gBACEX,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEe,eAAe;gBAAK;gBAC7Bb,QAAQ;oBACN;wBACEH,MAAM;wBACNG,QAAQ;4BACN;gCAAEJ,MAAM;gCAASC,MAAM;gCAAQC,OAAO;oCAAEgB,OAAO;gCAAM;gCAAGvB,UAAU;4BAAK;4BACvE;gCACEK,MAAM;gCACNC,MAAM;gCACNC,OAAO;oCAAEgB,OAAO;gCAAM;gCACtBC,QAAQ;gCACRX,cAAc;gCACdT,SAAS;oCACP;wCAAEY,OAAO;wCAA0BS,OAAO;oCAAO;oCACjD;wCAAET,OAAO;wCAAgBS,OAAO;oCAAU;iCAC3C;4BACH;4BACA;gCAAEpB,MAAM;gCAAWC,MAAM;gCAAYC,OAAO;oCAAEgB,OAAO;gCAAM;gCAAGV,cAAc;4BAAK;yBAClF;oBACH;oBACA;wBACER,MAAM;wBACNC,MAAM;wBACNC,OAAO;4BACLmB,WAAW,CAACC,IAAIC,UAAYA,SAASC,SAAS;4BAC9CrB,aAAa;wBACf;oBACF;oBACA;wBACEH,MAAM;wBACNC,MAAM;wBACNC,OAAO;4BACLmB,WAAW,CAACC,IAAIC,UAAYA,SAASC,SAAS;4BAC9CrB,aAAa;wBACf;oBACF;oBACA;wBACEF,MAAM;wBACNG,QAAQ;4BACN;gCAAEJ,MAAM;gCAAMC,MAAM;gCAAQC,OAAO;oCAAEgB,OAAO;gCAAM;4BAAE;4BACpD;gCAAElB,MAAM;gCAAOC,MAAM;gCAAQC,OAAO;oCAAEgB,OAAO;gCAAM;4BAAE;yBACtD;oBACH;oBACA;wBACEjB,MAAM;wBACNG,QAAQ;4BACN;gCAAEJ,MAAM;gCAAQC,MAAM;gCAAQC,OAAO;oCAAEC,aAAa;oCAAuCe,OAAO;gCAAM;4BAAE;4BAC1G;gCAAElB,MAAM;gCAAgBC,MAAM;gCAAQC,OAAO;oCAAEC,aAAa;oCAAsCe,OAAO;gCAAM;4BAAE;yBAClH;oBACH;oBACA;wBAAElB,MAAM;wBAAWC,MAAM;wBAAQO,cAAc;oBAAgC;oBAC/E;wBACER,MAAM;wBACNC,MAAM;wBACNC,OAAO;4BAAEC,aAAa;wBAA2F;wBACjHK,cAAc;oBAChB;iBACD;YACH;YACA;gBACER,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEe,eAAe;gBAAK;gBAC7Bb,QAAQ;oBACN;wBACEH,MAAM;wBACNG,QAAQ;4BACN;gCAAEJ,MAAM;gCAASC,MAAM;gCAAQC,OAAO;oCAAEgB,OAAO;gCAAM;gCAAGvB,UAAU;4BAAK;4BACvE;gCACEK,MAAM;gCACNC,MAAM;gCACNC,OAAO;oCAAEgB,OAAO;gCAAM;gCACtBC,QAAQ;gCACRX,cAAc;gCACdT,SAAS;oCAAC;oCAAQ;oCAAO;iCAAQ;4BACnC;4BACA;gCAAEC,MAAM;gCAAWC,MAAM;gCAAYC,OAAO;oCAAEgB,OAAO;gCAAM;gCAAGV,cAAc;4BAAK;yBAClF;oBACH;oBACA;wBAAER,MAAM;wBAAOC,MAAM;wBAAQC,OAAO;4BAAEC,aAAa;wBAAc;wBAAGR,UAAU;oBAAK;oBACnF;wBACEM,MAAM;wBACNG,QAAQ;4BACN;gCACEJ,MAAM;gCACNC,MAAM;gCACNC,OAAO;oCAAEgB,OAAO;gCAAM;gCACtBC,QAAQ;gCACRX,cAAc;gCACdT,SAAS;oCACP;wCAAEY,OAAO;wCAAoCS,OAAO;oCAAO;oCAC3D;wCAAET,OAAO;wCAAgBS,OAAO;oCAAc;iCAC/C;4BACH;4BACA;gCAAEpB,MAAM;gCAAaC,MAAM;gCAAUC,OAAO;oCAAEgB,OAAO;gCAAM;gCAAGV,cAAc;gCAAMiB,KAAK;gCAAOC,KAAK;4BAAI;4BACvG;gCAAE1B,MAAM;gCAAWC,MAAM;gCAAUC,OAAO;oCAAEgB,OAAO;gCAAM;gCAAGV,cAAc;gCAAGiB,KAAK;gCAAGC,KAAK;4BAAE;yBAC7F;oBACH;oBACA;wBACE1B,MAAM;wBACNC,MAAM;wBACNc,QAAQ;4BAAEY,QAAQd;4BAAQG,MAAMH;4BAAQe,QAAQf;wBAAO;wBACvDX,OAAO;4BAAEC,aAAa;4BAAiCc,eAAe;wBAAK;wBAC3Eb,QAAQ;4BACN;gCACEH,MAAM;gCACNG,QAAQ;oCACN;wCAAEJ,MAAM;wCAAOC,MAAM;wCAAQC,OAAO;4CAAEgB,OAAO;wCAAM;wCAAGvB,UAAU;oCAAK;oCACrE;wCAAEK,MAAM;wCAASC,MAAM;wCAAQC,OAAO;4CAAEgB,OAAO;wCAAM;wCAAGvB,UAAU;oCAAK;iCACxE;4BACH;yBACD;oBACH;oBACA;wBACEK,MAAM;wBACNC,MAAM;wBACNc,QAAQ;4BAAEY,QAAQd;4BAAQG,MAAMH;4BAAQe,QAAQf;wBAAO;wBACvDX,OAAO;4BAAEC,aAAa;wBAAoG;oBAC5H;iBACD;YACH;YACA;gBACEH,MAAM;gBACNC,MAAM;gBACNG,QAAQ;oBACN;wBAAEJ,MAAM;wBAAWC,MAAM;wBAAYO,cAAc;wBAAOG,OAAO;oBAAwB;oBACzF;wBACEX,MAAM;wBACNC,MAAM;wBACNC,OAAO;4BAAEmB,WAAW,CAACC,IAAIC,UAAYM,QAAQN,SAASO;4BAAU3B,aAAa;wBAA4B;oBAC3G;oBACA;wBACEH,MAAM;wBACNC,MAAM;wBACNC,OAAO;4BAAEmB,WAAW,CAACC,IAAIC,UAAYM,QAAQN,SAASO;wBAAS;wBAC/DtB,cAActB;wBACdwB,WAAW;oBACb;oBACA;wBACEV,MAAM;wBACNC,MAAM;wBACNC,OAAO;4BAAEmB,WAAW,CAACC,IAAIC,UAAYM,QAAQN,SAASO;wBAAS;wBAC/DtB,cAAcvB;wBACdyB,WAAW;oBACb;iBACD;gBACDC,OAAO;YACT;SACD;QACDA,OAAO;IACT;AACF;AAEA,OAAO,SAASoB,gBAAgBhC,OAAwB,EAAEiC,MAAe,EAAEC,SAAqB;IAC9F,OAAO;QACLC,MAAMnC,QAAQoC,KAAK,CAACC,KAAK;QACzBrB,QAAQ;YACNY,QAAQ5C;YACRsD,QAAQtD;YACRiC,MAAM,CAAC,EAAEsB,GAAG,EAAE,GAAMA,IAAIC,IAAI,GAAG,OAAO;oBAAEC,SAAS;wBAAEC,QAAQ;oBAAY;gBAAE;YACzEb,QAAQ7C;QACV;QACAmB,OAAO;YACLwC,gBAAgB;gBAAC;gBAAS;gBAAQ;gBAAe;gBAAW;aAAY;YACxEC,OAAO;YACPC,YAAY;QACd;QACAC,iBAAiB;YACfX,MAAM;YACNY,aAAa;YACbC,kBAAkB;YAClBC,UAAU;YACVC,oBAAoB;YACpBC,aAAa;YACbC,OAAO;YACPC,SAAS;QACX;QACAnB;QACA7B,QAAQ;YACN;gBAAEJ,MAAM;gBAASC,MAAM;gBAAQS,WAAW;gBAAMf,UAAU;YAAK;YAC/Dd,UAAU;gBAAEwE,WAAW;YAAQ;YAC/B;gBACEpD,MAAM;gBACNqD,MAAM;oBACJ;wBACElD,QAAQ;4BACN;gCACEH,MAAM;gCACNG,QAAQ;oCACN;wCACEJ,MAAM;wCACNC,MAAM;wCACNC,OAAO;4CAAEgB,OAAO;wCAAM;wCACtBC,QAAQ;wCACRX,cAAc;wCACdT,SAAS;4CACP;gDAAEY,OAAO;gDAAeS,OAAO;4CAAa;4CAC5C;gDAAET,OAAO;gDAAgBS,OAAO;4CAAQ;yCACzC;wCACDzB,UAAU;oCACZ;oCACA;wCACEK,MAAM;wCACNC,MAAM;wCACNC,OAAO;4CAAEgB,OAAO;wCAAM;wCACtBC,QAAQ;wCACRX,cAAc;wCACdT,SAAS;4CACP;gDAAEY,OAAO;gDAAQS,OAAO;4CAAO;4CAC/B;gDAAET,OAAO;gDAAUS,OAAO;4CAAS;yCACpC;wCACDzB,UAAU;oCACZ;iCACD;4BACH;4BACA;gCACEK,MAAM;gCACNC,MAAM;gCACNC,OAAO;oCAAEe,eAAe;gCAAK;gCAC7Bb,QAAQ;oCACN;wCACEH,MAAM;wCACNG,QAAQ;4CACN;gDAAEJ,MAAM;gDAAOC,MAAM;gDAAQC,OAAO;oDAAEgB,OAAO;gDAAM;gDAAGvB,UAAU;4CAAK;4CACrE;gDAAEK,MAAM;gDAASC,MAAM;gDAAQC,OAAO;oDAAEgB,OAAO;gDAAM;gDAAGR,WAAW;4CAAK;yCACzE;oCACH;oCACA;wCAAEV,MAAM;wCAAeC,MAAM;wCAAYS,WAAW;oCAAK;oCACzD;wCACEV,MAAM;wCACNC,MAAM;wCACNC,OAAO;4CAAEe,eAAe;wCAAK;wCAC7Be;oCACF;oCACA3C,eAAe,mBAAmB;oCAClCD;iCACD;gCACDmE,SAAS;gCACT5D,UAAU;4BACZ;yBACD;wBACDgB,OAAO;oBACT;oBACA;wBACEP,QAAQ;4BACN;gCACEJ,MAAM;gCACNC,MAAM;gCACNkB,QAAQ;gCACRX,cAAc;gCACdT,SAAS;oCACP;wCAAEY,OAAO;wCAA0BS,OAAO;oCAAU;oCACpD;wCAAET,OAAO;wCAAYS,OAAO;oCAAW;iCACxC;4BACH;4BACA;gCACEpB,MAAM;gCACNC,MAAM;gCACNC,OAAO;oCAAEmB,WAAW,CAACC,IAAIC,UAAYA,SAAS2B,gBAAgB;gCAAW;4BAC3E;4BACApD,aAAaC;yBACd;wBACDY,OAAO;oBACT;oBACA;wBAAEP,QAAQ;4BAACQ,cAAcb;yBAAS;wBAAEY,OAAO;oBAAW;iBACvD;YACH;SACD;QACD6C,OAAO;YACLC,gBAAgB;gBAACzE,uBAAuBe;aAAS;QACnD;QACA2D,UAAU;YACRC,QAAQ;YACRC,WAAW;QACb;IACF;AACF"}
1
+ {"version":3,"sources":["../../src/collections/forms.ts"],"sourcesContent":["import type { Block, CollectionConfig, Endpoint, Field, FieldAccess } from 'payload'\n\nimport { slugField } from 'payload'\n\nimport type { ResolvedOptions } from '../types.js'\n\nimport { adminFieldAccess, loggedIn } from '../access.js'\nimport { validateFormBeforeSave } from '../hooks/validateForm.js'\nimport { DEFAULT_WHATSAPP_ROW_TEMPLATE, DEFAULT_WHATSAPP_TEMPLATE } from '../shared/whatsapp.js'\nimport { WORD_KEYS } from '../shared/words.js'\nimport { nextStepRulesField, ruleGroupField } from './ruleFields.js'\n\nconst WORD_HELP: Record<string, string> = {\n errorPrefix: 'Shown above the field errors.',\n fileRejected: 'Field error for a file that is refused. {{label}} is the field label.',\n invalid: 'Field error for a bad value. {{label}} is the field label.',\n no: 'An unticked checkbox in emails, WhatsApp messages, webhooks and the stored snapshot.',\n required: 'Field error for a missing answer. {{label}} is the field label.',\n stepProgress: '{{current}} and {{total}} are the step numbers.',\n yes: 'A ticked checkbox in emails, WhatsApp messages, webhooks and the stored snapshot.',\n}\n\nfunction wordingField(options: ResolvedOptions): Field {\n return {\n name: 'wording',\n type: 'group',\n admin: { description: 'Every word visitors read besides the questions. New forms start with the defaults.' },\n fields: WORD_KEYS.map(\n (key): Field =>\n ({\n name: key,\n type: key === 'success' || key === 'closed' ? 'textarea' : 'text',\n admin: WORD_HELP[key] ? { description: WORD_HELP[key] } : undefined,\n defaultValue: options.words[key],\n localized: true,\n }) as Field,\n ),\n label: 'Form wording',\n }\n}\n\n/** The webhook row's admin-only secret fields; left out with `webhooks.secretsInAdmin: false`. */\nfunction secretFields(secret: FieldAccess): Field[] {\n return [\n {\n name: 'headers',\n type: 'array',\n access: { create: secret, read: secret, update: secret },\n admin: { description: 'Secret headers (admins only).', initCollapsed: true },\n fields: [\n {\n type: 'row',\n fields: [\n { name: 'key', type: 'text', admin: { width: '50%' }, required: true },\n { name: 'value', type: 'text', admin: { width: '50%' }, required: true },\n ],\n },\n ],\n },\n {\n name: 'signingSecret',\n type: 'text',\n access: { create: secret, read: secret, update: secret },\n admin: {\n description:\n 'Signs the body: X-Form-Signature: t=<seconds>,v1=<hex HMAC-SHA256 of \"<t>.<body>\">, plus the Standard Webhooks headers. Empty: the plugin config\\'s secret, if any (admins only).',\n },\n },\n ]\n}\n\nfunction deliveryField(options: ResolvedOptions): Field {\n const secret = adminFieldAccess(options.isAdmin)\n return {\n name: 'delivery',\n type: 'group',\n access: { read: loggedIn },\n fields: [\n {\n name: 'store',\n type: 'checkbox',\n admin: { description: 'Save each submission. Off: nothing is written; email, webhooks and WhatsApp still run.' },\n defaultValue: true,\n label: 'Store submissions',\n },\n {\n name: 'emails',\n type: 'array',\n admin: { initCollapsed: true },\n fields: [\n {\n type: 'row',\n fields: [\n { name: 'label', type: 'text', admin: { width: '50%' }, required: true },\n {\n name: 'kind',\n type: 'select',\n admin: { width: '30%' },\n dbName: 'k',\n defaultValue: 'team',\n options: [\n { label: 'Team (fixed addresses)', value: 'team' },\n { label: 'Visitor copy', value: 'visitor' },\n ],\n },\n { name: 'enabled', type: 'checkbox', admin: { width: '20%' }, defaultValue: true },\n ],\n },\n {\n name: 'to',\n type: 'text',\n admin: {\n condition: (_d, sibling) => sibling?.kind !== 'visitor',\n description: 'Fixed addresses, separated by commas. No {{tokens}}: use a visitor copy instead.',\n },\n },\n {\n name: 'visitorField',\n type: 'text',\n admin: {\n condition: (_d, sibling) => sibling?.kind === 'visitor',\n description: 'Key of the email field whose answer gets the copy.',\n },\n },\n {\n type: 'row',\n fields: [\n { name: 'cc', type: 'text', admin: { width: '50%' } },\n { name: 'bcc', type: 'text', admin: { width: '50%' } },\n ],\n },\n {\n type: 'row',\n fields: [\n { name: 'from', type: 'text', admin: { description: \"Empty: the email adapter's default.\", width: '50%' } },\n { name: 'replyToField', type: 'text', admin: { description: 'Key of an email field to reply to.', width: '50%' } },\n ],\n },\n { name: 'subject', type: 'text', defaultValue: 'New submission: {{formTitle}}' },\n {\n name: 'body',\n type: 'textarea',\n admin: { description: 'Tokens: {{key}}, {{key.attribute}}, {{*}}, {{*:table}}, {{formTitle}}, {{submissionId}}.' },\n defaultValue: '{{*:table}}',\n },\n ],\n },\n {\n name: 'webhooks',\n type: 'array',\n admin: { initCollapsed: true },\n fields: [\n {\n type: 'row',\n fields: [\n { name: 'label', type: 'text', admin: { width: '50%' }, required: true },\n {\n name: 'method',\n type: 'select',\n admin: { width: '30%' },\n dbName: 'mth',\n defaultValue: 'POST',\n options: ['POST', 'PUT', 'PATCH'],\n },\n { name: 'enabled', type: 'checkbox', admin: { width: '20%' }, defaultValue: true },\n ],\n },\n { name: 'url', type: 'text', admin: { description: 'https only.' }, required: true },\n {\n type: 'row',\n fields: [\n {\n name: 'payloadMode',\n type: 'select',\n admin: { width: '40%' },\n dbName: 'pmd',\n defaultValue: 'full',\n options: [\n { label: 'Full (form, submission, answers)', value: 'full' },\n { label: 'Answers only', value: 'answersOnly' },\n ],\n },\n { name: 'timeoutMs', type: 'number', admin: { width: '30%' }, defaultValue: 8000, max: 30000, min: 500 },\n { name: 'retries', type: 'number', admin: { width: '30%' }, defaultValue: 0, max: 3, min: 0 },\n ],\n },\n ...(options.webhooks.secretsInAdmin ? secretFields(secret) : []),\n ],\n },\n {\n name: 'whatsapp',\n type: 'group',\n fields: [\n { name: 'enabled', type: 'checkbox', defaultValue: false, label: 'Build a WhatsApp link' },\n {\n name: 'number',\n type: 'text',\n admin: { condition: (_d, sibling) => Boolean(sibling?.enabled), description: 'Empty: the app\\'s number.' },\n },\n {\n name: 'messageTemplate',\n type: 'textarea',\n admin: { condition: (_d, sibling) => Boolean(sibling?.enabled) },\n defaultValue: DEFAULT_WHATSAPP_TEMPLATE,\n localized: true,\n },\n {\n name: 'rowTemplate',\n type: 'text',\n admin: { condition: (_d, sibling) => Boolean(sibling?.enabled) },\n defaultValue: DEFAULT_WHATSAPP_ROW_TEMPLATE,\n localized: true,\n },\n ],\n label: 'WhatsApp',\n },\n ],\n label: 'Delivery',\n }\n}\n\nexport function formsCollection(options: ResolvedOptions, blocks: Block[], endpoints: Endpoint[]): CollectionConfig {\n return {\n slug: options.slugs.forms,\n access: {\n create: loggedIn,\n delete: loggedIn,\n read: ({ req }) => (req.user ? true : { _status: { equals: 'published' } }),\n update: loggedIn,\n },\n admin: {\n defaultColumns: ['title', 'slug', 'intakeState', '_status', 'updatedAt'],\n group: 'Forms',\n useAsTitle: 'title',\n },\n defaultPopulate: {\n slug: true,\n intakeState: true,\n presentationMode: true,\n sections: true,\n successRedirectURL: true,\n successType: true,\n title: true,\n wording: true,\n },\n endpoints,\n fields: [\n { name: 'title', type: 'text', localized: true, required: true },\n slugField({ useAsSlug: 'title' }),\n {\n type: 'tabs',\n tabs: [\n {\n fields: [\n {\n type: 'row',\n fields: [\n {\n name: 'presentationMode',\n type: 'select',\n admin: { width: '50%' },\n dbName: 'pm',\n defaultValue: 'singlePage',\n options: [\n { label: 'Single page', value: 'singlePage' },\n { label: 'Step by step', value: 'steps' },\n ],\n required: true,\n },\n {\n name: 'intakeState',\n type: 'select',\n admin: { width: '50%' },\n dbName: 'ist',\n defaultValue: 'open',\n options: [\n { label: 'Open', value: 'open' },\n { label: 'Closed', value: 'closed' },\n ],\n required: true,\n },\n ],\n },\n {\n name: 'sections',\n type: 'array',\n admin: { initCollapsed: true },\n fields: [\n {\n type: 'row',\n fields: [\n { name: 'key', type: 'text', admin: { width: '40%' }, required: true },\n { name: 'title', type: 'text', admin: { width: '60%' }, localized: true },\n ],\n },\n { name: 'description', type: 'textarea', localized: true },\n {\n name: 'fields',\n type: 'blocks',\n admin: { initCollapsed: true },\n blocks,\n },\n ruleGroupField('visibilityRules', 'Show section when'),\n nextStepRulesField(),\n ],\n minRows: 1,\n required: true,\n },\n ],\n label: 'Structure',\n },\n {\n fields: [\n {\n name: 'successType',\n type: 'radio',\n dbName: 'st',\n defaultValue: 'message',\n options: [\n { label: 'Show the success words', value: 'message' },\n { label: 'Redirect', value: 'redirect' },\n ],\n },\n {\n name: 'successRedirectURL',\n type: 'text',\n admin: { condition: (_d, sibling) => sibling?.successType === 'redirect' },\n },\n wordingField(options),\n ],\n label: 'Words',\n },\n { fields: [deliveryField(options)], label: 'Delivery' },\n ],\n },\n ],\n hooks: {\n beforeValidate: [validateFormBeforeSave(options)],\n },\n versions: {\n drafts: true,\n maxPerDoc: 20,\n },\n }\n}\n"],"names":["slugField","adminFieldAccess","loggedIn","validateFormBeforeSave","DEFAULT_WHATSAPP_ROW_TEMPLATE","DEFAULT_WHATSAPP_TEMPLATE","WORD_KEYS","nextStepRulesField","ruleGroupField","WORD_HELP","errorPrefix","fileRejected","invalid","no","required","stepProgress","yes","wordingField","options","name","type","admin","description","fields","map","key","undefined","defaultValue","words","localized","label","secretFields","secret","access","create","read","update","initCollapsed","width","deliveryField","isAdmin","dbName","value","condition","_d","sibling","kind","max","min","webhooks","secretsInAdmin","Boolean","enabled","formsCollection","blocks","endpoints","slug","slugs","forms","delete","req","user","_status","equals","defaultColumns","group","useAsTitle","defaultPopulate","intakeState","presentationMode","sections","successRedirectURL","successType","title","wording","useAsSlug","tabs","minRows","hooks","beforeValidate","versions","drafts","maxPerDoc"],"mappings":"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"}
@@ -27,6 +27,7 @@ export type DeliveryContext = {
27
27
  snapshot: SnapshotRow[];
28
28
  submissionId?: number | string;
29
29
  };
30
+ export declare const NO_SECRET_ERROR = "Webhook not sent: no signing secret.";
30
31
  /** Runs the enabled email and webhook outputs (or only `only`), each catching its own errors. */
31
32
  export declare function runDeliveries(ctx: DeliveryContext, only?: Set<string>): Promise<LogEntry[]>;
32
33
  export declare function hasDeliveries(delivery: FormDelivery): boolean;
@@ -1,6 +1,6 @@
1
1
  import { isValidEmail } from '../shared/answers.js';
2
2
  import { buildMessageRows, buildTemplateValues, interpolate } from '../shared/tokens.js';
3
- import { sendWebhook } from './webhook.js';
3
+ import { sendWebhook, webhookMessageId } from './webhook.js';
4
4
  function addresses(value) {
5
5
  const list = String(value || '').split(/[,;]/).map((v)=>v.trim()).filter(Boolean);
6
6
  return list.length ? list.join(', ') : undefined;
@@ -97,8 +97,52 @@ async function sendOneEmail(ctx, output) {
97
97
  };
98
98
  }
99
99
  }
100
+ /** Webhooks already warned about being unsigned in this server process (form id + output id). */ const warnedUnsigned = new Set();
101
+ export const NO_SECRET_ERROR = 'Webhook not sent: no signing secret.';
102
+ async function configValue(value, args) {
103
+ return typeof value === 'function' ? await value(args) : value;
104
+ }
105
+ function nonEmpty(value) {
106
+ return typeof value === 'string' && value.trim() !== '' ? value : undefined;
107
+ }
108
+ /** The webhook's admin secret when non-empty, else the config one (value or function). */ async function resolveSigningSecret(ctx, output, args) {
109
+ return nonEmpty(output.signingSecret) ?? nonEmpty(await configValue(ctx.options.webhooks?.signingSecret, args));
110
+ }
100
111
  async function sendOneWebhook(ctx, output) {
101
112
  const at = new Date().toISOString();
113
+ const base = {
114
+ at,
115
+ kind: 'webhook',
116
+ label: output.label || null,
117
+ outputId: output.id || null,
118
+ target: output.url || null
119
+ };
120
+ const args = {
121
+ form: ctx.form,
122
+ req: ctx.req,
123
+ webhook: {
124
+ id: output.id || null,
125
+ label: output.label || null,
126
+ url: String(output.url || '')
127
+ }
128
+ };
129
+ const signingSecret = await resolveSigningSecret(ctx, output, args);
130
+ if (!signingSecret) {
131
+ if (ctx.options.webhooks?.requireSignature) {
132
+ return {
133
+ ...base,
134
+ error: NO_SECRET_ERROR,
135
+ result: 'failed',
136
+ tries: 0
137
+ };
138
+ }
139
+ const key = `${String(ctx.form.id)}:${output.id ?? output.url ?? ''}`;
140
+ if (!warnedUnsigned.has(key)) {
141
+ warnedUnsigned.add(key);
142
+ ctx.payload?.logger?.warn(`payload-dynamic-forms: webhook "${output.label || ''}" on form "${ctx.form.slug}" is sent unsigned; set a signing secret.`);
143
+ }
144
+ }
145
+ const configHeaders = await configValue(ctx.options.webhooks?.headers, args) || null;
102
146
  const body = output.payloadMode === 'answersOnly' ? {
103
147
  answers: ctx.answers
104
148
  } : {
@@ -114,15 +158,18 @@ async function sendOneWebhook(ctx, output) {
114
158
  snapshot: ctx.snapshot
115
159
  }
116
160
  };
117
- const result = await sendWebhook(output, body);
161
+ // Stable for a stored submission (retries and resends); random once per delivery otherwise.
162
+ const webhookId = webhookMessageId(ctx.submissionId, output.id);
163
+ const result = await sendWebhook({
164
+ ...output,
165
+ configHeaders,
166
+ signingSecret,
167
+ webhookId
168
+ }, body);
118
169
  return {
119
- at,
170
+ ...base,
120
171
  error: result.ok ? null : result.error || 'Webhook failed.',
121
- kind: 'webhook',
122
- label: output.label || null,
123
- outputId: output.id || null,
124
172
  result: result.ok ? 'success' : 'failed',
125
- target: output.url || null,
126
173
  tries: result.tries
127
174
  };
128
175
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/delivery/run.ts"],"sourcesContent":["import type { Payload, PayloadRequest, SendEmailOptions } from 'payload'\n\nimport type { EmailOutput, FormDelivery, WebhookOutput } from '../server/forms.js'\nimport type { Answers, PublicForm, SnapshotRow, SubmissionStatus } from '../shared/types.js'\nimport type { ResolvedOptions } from '../types.js'\n\nimport { isValidEmail } from '../shared/answers.js'\nimport { buildMessageRows, buildTemplateValues, interpolate } from '../shared/tokens.js'\nimport { sendWebhook } from './webhook.js'\n\nexport type LogEntry = {\n at: string\n error?: null | string\n kind: 'email' | 'webhook'\n label?: null | string\n outputId?: null | string\n result: 'failed' | 'skipped' | 'success'\n target?: null | string\n tries?: null | number\n}\n\nexport type DeliveryContext = {\n answers: Answers\n attachments?: Array<{ fieldKey: string; filename: string; id: number | string }>\n delivery: FormDelivery\n form: PublicForm\n options: ResolvedOptions\n payload: Payload\n req: PayloadRequest\n snapshot: SnapshotRow[]\n submissionId?: number | string\n}\n\nfunction addresses(value: null | string | undefined): string | undefined {\n const list = String(value || '')\n .split(/[,;]/)\n .map((v) => v.trim())\n .filter(Boolean)\n return list.length ? list.join(', ') : undefined\n}\n\nfunction errorText(error: unknown): string {\n return error instanceof Error ? error.message : String(error)\n}\n\nasync function sendOneEmail(ctx: DeliveryContext, output: EmailOutput): Promise<LogEntry> {\n const at = new Date().toISOString()\n const base = { at, kind: 'email' as const, label: output.label || null, outputId: output.id || null }\n let to: string | undefined\n if (output.kind === 'visitor') {\n const answer = ctx.answers[String(output.visitorField || '')]\n const address = typeof answer === 'string' ? answer.trim() : ''\n if (!isValidEmail(address)) {\n return {\n ...base,\n error: `Visitor copy not sent: \"${address}\" is not one valid email address.`,\n result: 'failed',\n target: address || null,\n tries: 0,\n }\n }\n to = address\n } else {\n to = addresses(output.to)\n }\n if (!to) {\n return { ...base, error: 'No address to send to.', result: 'failed', target: null, tries: 0 }\n }\n const rows = buildMessageRows(ctx.form, ctx.answers, ctx.options.fieldTypes)\n const values = buildTemplateValues(\n ctx.form,\n ctx.answers,\n { formTitle: ctx.form.title, submissionId: ctx.submissionId === undefined ? '' : String(ctx.submissionId) },\n ctx.options.fieldTypes,\n )\n const body = output.body || '{{*:table}}'\n const replyKey = String(output.replyToField || '')\n const replyTo = replyKey && isValidEmail(ctx.answers[replyKey]) ? String(ctx.answers[replyKey]).trim() : undefined\n let message: SendEmailOptions = {\n bcc: addresses(output.bcc),\n cc: addresses(output.cc),\n from: output.from?.trim() || undefined,\n html: interpolate(body.split('\\n').join('<br>\\n'), values, { html: true, rows }),\n replyTo,\n subject: interpolate(output.subject || 'New submission: {{formTitle}}', values, { singleLine: true }),\n text: interpolate(body, values, { rows }),\n to,\n }\n for (const key of Object.keys(message) as Array<keyof SendEmailOptions>) {\n if (message[key] === undefined) {\n delete message[key]\n }\n }\n try {\n if (ctx.options.beforeEmail) {\n message = await ctx.options.beforeEmail(message, { answers: ctx.answers, form: ctx.form, req: ctx.req })\n }\n await ctx.payload.sendEmail(message)\n return { ...base, result: 'success', target: to, tries: 1 }\n } catch (error) {\n return { ...base, error: errorText(error), result: 'failed', target: to, tries: 1 }\n }\n}\n\nasync function sendOneWebhook(ctx: DeliveryContext, output: WebhookOutput): Promise<LogEntry> {\n const at = new Date().toISOString()\n const body =\n output.payloadMode === 'answersOnly'\n ? { answers: ctx.answers }\n : {\n answers: ctx.answers,\n form: { id: ctx.form.id, slug: ctx.form.slug, title: ctx.form.title },\n submission: {\n id: ctx.submissionId ?? null,\n attachments: ctx.attachments || [],\n snapshot: ctx.snapshot,\n },\n }\n const result = await sendWebhook(output, body)\n return {\n at,\n error: result.ok ? null : result.error || 'Webhook failed.',\n kind: 'webhook',\n label: output.label || null,\n outputId: output.id || null,\n result: result.ok ? 'success' : 'failed',\n target: output.url || null,\n tries: result.tries,\n }\n}\n\n/** Runs the enabled email and webhook outputs (or only `only`), each catching its own errors. */\nexport async function runDeliveries(ctx: DeliveryContext, only?: Set<string>): Promise<LogEntry[]> {\n const entries: LogEntry[] = []\n for (const output of ctx.delivery.emails) {\n if (output.enabled === false || (only && !only.has(String(output.id)))) {\n continue\n }\n entries.push(await sendOneEmail(ctx, output))\n }\n for (const output of ctx.delivery.webhooks) {\n if (output.enabled === false || (only && !only.has(String(output.id)))) {\n continue\n }\n try {\n entries.push(await sendOneWebhook(ctx, output))\n } catch (error) {\n entries.push({\n at: new Date().toISOString(),\n error: errorText(error),\n kind: 'webhook',\n label: output.label || null,\n outputId: output.id || null,\n result: 'failed',\n target: output.url || null,\n tries: 0,\n })\n }\n }\n return entries\n}\n\nexport function hasDeliveries(delivery: FormDelivery): boolean {\n return delivery.emails.some((e) => e.enabled !== false) || delivery.webhooks.some((w) => w.enabled !== false)\n}\n\n/** The last entry of each output, ignoring outputs whose last entry is \"skipped\". */\nexport function lastResults(log: LogEntry[]): Map<string, LogEntry> {\n const last = new Map<string, LogEntry>()\n log.forEach((entry, index) => {\n last.set(entry.outputId || `#${index}`, entry)\n })\n for (const [key, entry] of last) {\n if (entry.result === 'skipped') {\n last.delete(key)\n }\n }\n return last\n}\n\n/** processed: all succeeded or none ran; partlyFailed: some failed; failed: all failed. */\nexport function statusFromLog(log: LogEntry[]): SubmissionStatus {\n const results = [...lastResults(log).values()]\n if (!results.length) {\n return 'processed'\n }\n const ok = results.filter((r) => r.result === 'success').length\n if (ok === results.length) {\n return 'processed'\n }\n return ok === 0 ? 'failed' : 'partlyFailed'\n}\n"],"names":["isValidEmail","buildMessageRows","buildTemplateValues","interpolate","sendWebhook","addresses","value","list","String","split","map","v","trim","filter","Boolean","length","join","undefined","errorText","error","Error","message","sendOneEmail","ctx","output","at","Date","toISOString","base","kind","label","outputId","id","to","answer","answers","visitorField","address","result","target","tries","rows","form","options","fieldTypes","values","formTitle","title","submissionId","body","replyKey","replyToField","replyTo","bcc","cc","from","html","subject","singleLine","text","key","Object","keys","beforeEmail","req","payload","sendEmail","sendOneWebhook","payloadMode","slug","submission","attachments","snapshot","ok","url","runDeliveries","only","entries","delivery","emails","enabled","has","push","webhooks","hasDeliveries","some","e","w","lastResults","log","last","Map","forEach","entry","index","set","delete","statusFromLog","results","r"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/delivery/run.ts"],"sourcesContent":["import type { Payload, PayloadRequest, SendEmailOptions } from 'payload'\n\nimport type { EmailOutput, FormDelivery, WebhookOutput } from '../server/forms.js'\nimport type { Answers, PublicForm, SnapshotRow, SubmissionStatus } from '../shared/types.js'\nimport type { ResolvedOptions, WebhookConfigArgs, WebhookConfigValue } from '../types.js'\n\nimport { isValidEmail } from '../shared/answers.js'\nimport { buildMessageRows, buildTemplateValues, interpolate } from '../shared/tokens.js'\nimport { sendWebhook, webhookMessageId } from './webhook.js'\n\nexport type LogEntry = {\n at: string\n error?: null | string\n kind: 'email' | 'webhook'\n label?: null | string\n outputId?: null | string\n result: 'failed' | 'skipped' | 'success'\n target?: null | string\n tries?: null | number\n}\n\nexport type DeliveryContext = {\n answers: Answers\n attachments?: Array<{ fieldKey: string; filename: string; id: number | string }>\n delivery: FormDelivery\n form: PublicForm\n options: ResolvedOptions\n payload: Payload\n req: PayloadRequest\n snapshot: SnapshotRow[]\n submissionId?: number | string\n}\n\nfunction addresses(value: null | string | undefined): string | undefined {\n const list = String(value || '')\n .split(/[,;]/)\n .map((v) => v.trim())\n .filter(Boolean)\n return list.length ? list.join(', ') : undefined\n}\n\nfunction errorText(error: unknown): string {\n return error instanceof Error ? error.message : String(error)\n}\n\nasync function sendOneEmail(ctx: DeliveryContext, output: EmailOutput): Promise<LogEntry> {\n const at = new Date().toISOString()\n const base = { at, kind: 'email' as const, label: output.label || null, outputId: output.id || null }\n let to: string | undefined\n if (output.kind === 'visitor') {\n const answer = ctx.answers[String(output.visitorField || '')]\n const address = typeof answer === 'string' ? answer.trim() : ''\n if (!isValidEmail(address)) {\n return {\n ...base,\n error: `Visitor copy not sent: \"${address}\" is not one valid email address.`,\n result: 'failed',\n target: address || null,\n tries: 0,\n }\n }\n to = address\n } else {\n to = addresses(output.to)\n }\n if (!to) {\n return { ...base, error: 'No address to send to.', result: 'failed', target: null, tries: 0 }\n }\n const rows = buildMessageRows(ctx.form, ctx.answers, ctx.options.fieldTypes)\n const values = buildTemplateValues(\n ctx.form,\n ctx.answers,\n { formTitle: ctx.form.title, submissionId: ctx.submissionId === undefined ? '' : String(ctx.submissionId) },\n ctx.options.fieldTypes,\n )\n const body = output.body || '{{*:table}}'\n const replyKey = String(output.replyToField || '')\n const replyTo = replyKey && isValidEmail(ctx.answers[replyKey]) ? String(ctx.answers[replyKey]).trim() : undefined\n let message: SendEmailOptions = {\n bcc: addresses(output.bcc),\n cc: addresses(output.cc),\n from: output.from?.trim() || undefined,\n html: interpolate(body.split('\\n').join('<br>\\n'), values, { html: true, rows }),\n replyTo,\n subject: interpolate(output.subject || 'New submission: {{formTitle}}', values, { singleLine: true }),\n text: interpolate(body, values, { rows }),\n to,\n }\n for (const key of Object.keys(message) as Array<keyof SendEmailOptions>) {\n if (message[key] === undefined) {\n delete message[key]\n }\n }\n try {\n if (ctx.options.beforeEmail) {\n message = await ctx.options.beforeEmail(message, { answers: ctx.answers, form: ctx.form, req: ctx.req })\n }\n await ctx.payload.sendEmail(message)\n return { ...base, result: 'success', target: to, tries: 1 }\n } catch (error) {\n return { ...base, error: errorText(error), result: 'failed', target: to, tries: 1 }\n }\n}\n\n/** Webhooks already warned about being unsigned in this server process (form id + output id). */\nconst warnedUnsigned = new Set<string>()\n\nexport const NO_SECRET_ERROR = 'Webhook not sent: no signing secret.'\n\nasync function configValue<T>(value: undefined | WebhookConfigValue<T>, args: WebhookConfigArgs): Promise<T | undefined> {\n return typeof value === 'function'\n ? await (value as (args: WebhookConfigArgs) => Promise<T | undefined> | T | undefined)(args)\n : value\n}\n\nfunction nonEmpty(value: unknown): string | undefined {\n return typeof value === 'string' && value.trim() !== '' ? value : undefined\n}\n\n/** The webhook's admin secret when non-empty, else the config one (value or function). */\nasync function resolveSigningSecret(ctx: DeliveryContext, output: WebhookOutput, args: WebhookConfigArgs): Promise<string | undefined> {\n return nonEmpty(output.signingSecret) ?? nonEmpty(await configValue(ctx.options.webhooks?.signingSecret, args))\n}\n\nasync function sendOneWebhook(ctx: DeliveryContext, output: WebhookOutput): Promise<LogEntry> {\n const at = new Date().toISOString()\n const base = { at, kind: 'webhook' as const, label: output.label || null, outputId: output.id || null, target: output.url || null }\n const args: WebhookConfigArgs = {\n form: ctx.form,\n req: ctx.req,\n webhook: { id: output.id || null, label: output.label || null, url: String(output.url || '') },\n }\n const signingSecret = await resolveSigningSecret(ctx, output, args)\n if (!signingSecret) {\n if (ctx.options.webhooks?.requireSignature) {\n return { ...base, error: NO_SECRET_ERROR, result: 'failed', tries: 0 }\n }\n const key = `${String(ctx.form.id)}:${output.id ?? output.url ?? ''}`\n if (!warnedUnsigned.has(key)) {\n warnedUnsigned.add(key)\n ctx.payload?.logger?.warn(\n `payload-dynamic-forms: webhook \"${output.label || ''}\" on form \"${ctx.form.slug}\" is sent unsigned; set a signing secret.`,\n )\n }\n }\n const configHeaders = (await configValue(ctx.options.webhooks?.headers, args)) || null\n const body =\n output.payloadMode === 'answersOnly'\n ? { answers: ctx.answers }\n : {\n answers: ctx.answers,\n form: { id: ctx.form.id, slug: ctx.form.slug, title: ctx.form.title },\n submission: {\n id: ctx.submissionId ?? null,\n attachments: ctx.attachments || [],\n snapshot: ctx.snapshot,\n },\n }\n // Stable for a stored submission (retries and resends); random once per delivery otherwise.\n const webhookId = webhookMessageId(ctx.submissionId, output.id)\n const result = await sendWebhook({ ...output, configHeaders, signingSecret, webhookId }, body)\n return {\n ...base,\n error: result.ok ? null : result.error || 'Webhook failed.',\n result: result.ok ? 'success' : 'failed',\n tries: result.tries,\n }\n}\n\n/** Runs the enabled email and webhook outputs (or only `only`), each catching its own errors. */\nexport async function runDeliveries(ctx: DeliveryContext, only?: Set<string>): Promise<LogEntry[]> {\n const entries: LogEntry[] = []\n for (const output of ctx.delivery.emails) {\n if (output.enabled === false || (only && !only.has(String(output.id)))) {\n continue\n }\n entries.push(await sendOneEmail(ctx, output))\n }\n for (const output of ctx.delivery.webhooks) {\n if (output.enabled === false || (only && !only.has(String(output.id)))) {\n continue\n }\n try {\n entries.push(await sendOneWebhook(ctx, output))\n } catch (error) {\n entries.push({\n at: new Date().toISOString(),\n error: errorText(error),\n kind: 'webhook',\n label: output.label || null,\n outputId: output.id || null,\n result: 'failed',\n target: output.url || null,\n tries: 0,\n })\n }\n }\n return entries\n}\n\nexport function hasDeliveries(delivery: FormDelivery): boolean {\n return delivery.emails.some((e) => e.enabled !== false) || delivery.webhooks.some((w) => w.enabled !== false)\n}\n\n/** The last entry of each output, ignoring outputs whose last entry is \"skipped\". */\nexport function lastResults(log: LogEntry[]): Map<string, LogEntry> {\n const last = new Map<string, LogEntry>()\n log.forEach((entry, index) => {\n last.set(entry.outputId || `#${index}`, entry)\n })\n for (const [key, entry] of last) {\n if (entry.result === 'skipped') {\n last.delete(key)\n }\n }\n return last\n}\n\n/** processed: all succeeded or none ran; partlyFailed: some failed; failed: all failed. */\nexport function statusFromLog(log: LogEntry[]): SubmissionStatus {\n const results = [...lastResults(log).values()]\n if (!results.length) {\n return 'processed'\n }\n const ok = results.filter((r) => r.result === 'success').length\n if (ok === results.length) {\n return 'processed'\n }\n return ok === 0 ? 'failed' : 'partlyFailed'\n}\n"],"names":["isValidEmail","buildMessageRows","buildTemplateValues","interpolate","sendWebhook","webhookMessageId","addresses","value","list","String","split","map","v","trim","filter","Boolean","length","join","undefined","errorText","error","Error","message","sendOneEmail","ctx","output","at","Date","toISOString","base","kind","label","outputId","id","to","answer","answers","visitorField","address","result","target","tries","rows","form","options","fieldTypes","values","formTitle","title","submissionId","body","replyKey","replyToField","replyTo","bcc","cc","from","html","subject","singleLine","text","key","Object","keys","beforeEmail","req","payload","sendEmail","warnedUnsigned","Set","NO_SECRET_ERROR","configValue","args","nonEmpty","resolveSigningSecret","signingSecret","webhooks","sendOneWebhook","url","webhook","requireSignature","has","add","logger","warn","slug","configHeaders","headers","payloadMode","submission","attachments","snapshot","webhookId","ok","runDeliveries","only","entries","delivery","emails","enabled","push","hasDeliveries","some","e","w","lastResults","log","last","Map","forEach","entry","index","set","delete","statusFromLog","results","r"],"mappings":"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"}
@@ -1,13 +1,25 @@
1
1
  export type WebhookSettings = {
2
+ /**
3
+ * Headers applied under the admin `headers` (e.g. resolved from the plugin config). A later
4
+ * header replaces an earlier one of the same name, in any case.
5
+ */
6
+ configHeaders?: null | Record<string, string>;
7
+ /** The webhook's own (admin) secret headers. */
2
8
  headers?: Array<{
3
9
  key?: null | string;
4
10
  value?: null | string;
5
11
  }> | null;
6
12
  method?: 'PATCH' | 'POST' | 'PUT' | null;
7
13
  retries?: null | number;
14
+ /** The secret to sign with, already resolved. Empty: the request is sent unsigned. */
8
15
  signingSecret?: null | string;
9
16
  timeoutMs?: null | number;
10
17
  url?: null | string;
18
+ /**
19
+ * The Standard Webhooks `webhook-id`, the same on every try. Missing: `msg_` plus a random id
20
+ * made once per call. Characters outside `[A-Za-z0-9_]` become `_`.
21
+ */
22
+ webhookId?: null | string;
11
23
  };
12
24
  export type WebhookResult = {
13
25
  error?: string;
@@ -19,8 +31,11 @@ export type WebhookResult = {
19
31
  export declare const RETRY_PAUSES_MS: number[];
20
32
  /** `X-Form-Signature` value: `t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">`. */
21
33
  export declare function signWebhookBody(secret: string, body: string, now?: number): Promise<string>;
34
+ /** `msg_<submissionId>_<outputId>` when both exist, else `msg_` plus a random id; only `[A-Za-z0-9_]`. */
35
+ export declare function webhookMessageId(submissionId?: null | number | string, outputId?: null | string): string;
22
36
  /**
23
- * Sends one webhook: JSON body, secret headers, optional signature, a timeout per try (default
24
- * 8 s) and up to 3 retries with growing pauses. A non-2xx answer or a timeout is a failed try.
37
+ * Sends one webhook: JSON body, config headers under the secret headers, an optional signature
38
+ * (given resolved), a timeout per try (default 8 s) and up to 3 retries with growing pauses. A
39
+ * non-2xx answer or a timeout is a failed try.
25
40
  */
26
41
  export declare function sendWebhook(settings: WebhookSettings, body: unknown): Promise<WebhookResult>;
@@ -1,4 +1,4 @@
1
- import { hmacSha256Hex } from '../security/crypto.js';
1
+ import { hmacSha256Base64, hmacSha256Hex, standardWebhooksKey } from '../security/crypto.js';
2
2
  /** Pauses between tries: 0.5 s, 1 s, 2 s. */ export const RETRY_PAUSES_MS = [
3
3
  500,
4
4
  1000,
@@ -9,13 +9,51 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
9
9
  const t = Math.floor(now / 1000);
10
10
  return `t=${t},v1=${await hmacSha256Hex(secret, `${t}.${body}`)}`;
11
11
  }
12
+ /** `msg_<submissionId>_<outputId>` when both exist, else `msg_` plus a random id; only `[A-Za-z0-9_]`. */ export function webhookMessageId(submissionId, outputId) {
13
+ const raw = submissionId !== undefined && submissionId !== null && submissionId !== '' && outputId ? `msg_${submissionId}_${outputId}` : `msg_${crypto.randomUUID().replace(/-/g, '')}`;
14
+ return raw.replace(/\W/g, '_');
15
+ }
12
16
  /**
13
- * Sends one webhook: JSON body, secret headers, optional signature, a timeout per try (default
14
- * 8 s) and up to 3 retries with growing pauses. A non-2xx answer or a timeout is a failed try.
17
+ * Request headers in order, a later one replacing an earlier one whose name matches in any case:
18
+ * `Content-Type`, then each group in turn.
19
+ */ function mergeHeaders(...groups) {
20
+ const merged = new Map([
21
+ [
22
+ 'content-type',
23
+ [
24
+ 'Content-Type',
25
+ 'application/json'
26
+ ]
27
+ ]
28
+ ]);
29
+ for (const group of groups){
30
+ for (const [key, value] of group){
31
+ merged.set(key.toLowerCase(), [
32
+ key,
33
+ value
34
+ ]);
35
+ }
36
+ }
37
+ return Object.fromEntries(merged.values());
38
+ }
39
+ /**
40
+ * Sends one webhook: JSON body, config headers under the secret headers, an optional signature
41
+ * (given resolved), a timeout per try (default 8 s) and up to 3 retries with growing pauses. A
42
+ * non-2xx answer or a timeout is a failed try.
15
43
  */ export async function sendWebhook(settings, body) {
16
44
  const json = JSON.stringify(body);
17
45
  const timeoutMs = Math.min(30_000, Math.max(500, Number(settings.timeoutMs) || 8000));
18
46
  const retries = Math.min(3, Math.max(0, Math.floor(Number(settings.retries) || 0)));
47
+ const configHeaders = Object.entries(settings.configHeaders || {}).filter((entry)=>Boolean(entry[0]) && typeof entry[1] === 'string');
48
+ const adminHeaders = (settings.headers || []).flatMap((header)=>header?.key && header.value ? [
49
+ [
50
+ header.key,
51
+ header.value
52
+ ]
53
+ ] : []);
54
+ const secret = settings.signingSecret || '';
55
+ const webhookId = settings.webhookId ? settings.webhookId.replace(/\W/g, '_') : webhookMessageId();
56
+ const key = secret ? standardWebhooksKey(secret) : null;
19
57
  let lastError = '';
20
58
  let lastStatus;
21
59
  let tries = 0;
@@ -24,17 +62,28 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
24
62
  await sleep(RETRY_PAUSES_MS[Math.min(attempt - 1, RETRY_PAUSES_MS.length - 1)]);
25
63
  }
26
64
  tries += 1;
27
- const headers = {
28
- 'Content-Type': 'application/json'
29
- };
30
- for (const header of settings.headers || []){
31
- if (header?.key && header.value) {
32
- headers[header.key] = header.value;
33
- }
34
- }
35
- if (settings.signingSecret) {
36
- headers['X-Form-Signature'] = await signWebhookBody(settings.signingSecret, json);
65
+ // Signature headers come last: neither config nor admin headers can replace them.
66
+ const signature = [
67
+ [
68
+ 'webhook-id',
69
+ webhookId
70
+ ]
71
+ ];
72
+ if (secret && key) {
73
+ const now = Date.now();
74
+ const t = Math.floor(now / 1000);
75
+ signature.push([
76
+ 'X-Form-Signature',
77
+ await signWebhookBody(secret, json, now)
78
+ ], [
79
+ 'webhook-timestamp',
80
+ String(t)
81
+ ], [
82
+ 'webhook-signature',
83
+ `v1,${await hmacSha256Base64(key, `${webhookId}.${t}.${json}`)}`
84
+ ]);
37
85
  }
86
+ const headers = mergeHeaders(configHeaders, adminHeaders, signature);
38
87
  const controller = new AbortController();
39
88
  const timer = setTimeout(()=>controller.abort(), timeoutMs);
40
89
  try {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/delivery/webhook.ts"],"sourcesContent":["import { hmacSha256Hex } from '../security/crypto.js'\n\nexport type WebhookSettings = {\n headers?: Array<{ key?: null | string; value?: null | string }> | null\n method?: 'PATCH' | 'POST' | 'PUT' | null\n retries?: null | number\n signingSecret?: null | string\n timeoutMs?: null | number\n url?: null | string\n}\n\nexport type WebhookResult = { error?: string; ok: boolean; status?: number; tries: number }\n\n/** Pauses between tries: 0.5 s, 1 s, 2 s. */\nexport const RETRY_PAUSES_MS = [500, 1000, 2000]\n\nconst sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))\n\n/** `X-Form-Signature` value: `t=<unix seconds>,v1=<hex HMAC-SHA256 of \"<t>.<body>\">`. */\nexport async function signWebhookBody(secret: string, body: string, now = Date.now()): Promise<string> {\n const t = Math.floor(now / 1000)\n return `t=${t},v1=${await hmacSha256Hex(secret, `${t}.${body}`)}`\n}\n\n/**\n * Sends one webhook: JSON body, secret headers, optional signature, a timeout per try (default\n * 8 s) and up to 3 retries with growing pauses. A non-2xx answer or a timeout is a failed try.\n */\nexport async function sendWebhook(settings: WebhookSettings, body: unknown): Promise<WebhookResult> {\n const json = JSON.stringify(body)\n const timeoutMs = Math.min(30_000, Math.max(500, Number(settings.timeoutMs) || 8000))\n const retries = Math.min(3, Math.max(0, Math.floor(Number(settings.retries) || 0)))\n let lastError = ''\n let lastStatus: number | undefined\n let tries = 0\n for (let attempt = 0; attempt <= retries; attempt += 1) {\n if (attempt > 0) {\n await sleep(RETRY_PAUSES_MS[Math.min(attempt - 1, RETRY_PAUSES_MS.length - 1)])\n }\n tries += 1\n const headers: Record<string, string> = { 'Content-Type': 'application/json' }\n for (const header of settings.headers || []) {\n if (header?.key && header.value) {\n headers[header.key] = header.value\n }\n }\n if (settings.signingSecret) {\n headers['X-Form-Signature'] = await signWebhookBody(settings.signingSecret, json)\n }\n const controller = new AbortController()\n const timer = setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(String(settings.url || ''), {\n body: json,\n headers,\n method: settings.method || 'POST',\n signal: controller.signal,\n })\n lastStatus = response.status\n if (response.ok) {\n return { ok: true, status: response.status, tries }\n }\n lastError = `Webhook answered ${response.status}.`\n } catch (error) {\n lastStatus = undefined\n lastError = controller.signal.aborted\n ? `Webhook timed out after ${timeoutMs} ms.`\n : error instanceof Error\n ? error.message\n : String(error)\n } finally {\n clearTimeout(timer)\n }\n }\n return { error: lastError, ok: false, status: lastStatus, tries }\n}\n"],"names":["hmacSha256Hex","RETRY_PAUSES_MS","sleep","ms","Promise","resolve","setTimeout","signWebhookBody","secret","body","now","Date","t","Math","floor","sendWebhook","settings","json","JSON","stringify","timeoutMs","min","max","Number","retries","lastError","lastStatus","tries","attempt","length","headers","header","key","value","signingSecret","controller","AbortController","timer","abort","response","fetch","String","url","method","signal","status","ok","error","undefined","aborted","Error","message","clearTimeout"],"mappings":"AAAA,SAASA,aAAa,QAAQ,wBAAuB;AAarD,2CAA2C,GAC3C,OAAO,MAAMC,kBAAkB;IAAC;IAAK;IAAM;CAAK,CAAA;AAEhD,MAAMC,QAAQ,CAACC,KAAe,IAAIC,QAAc,CAACC,UAAYC,WAAWD,SAASF;AAEjF,uFAAuF,GACvF,OAAO,eAAeI,gBAAgBC,MAAc,EAAEC,IAAY,EAAEC,MAAMC,KAAKD,GAAG,EAAE;IAClF,MAAME,IAAIC,KAAKC,KAAK,CAACJ,MAAM;IAC3B,OAAO,CAAC,EAAE,EAAEE,EAAE,IAAI,EAAE,MAAMZ,cAAcQ,QAAQ,GAAGI,EAAE,CAAC,EAAEH,MAAM,GAAG;AACnE;AAEA;;;CAGC,GACD,OAAO,eAAeM,YAAYC,QAAyB,EAAEP,IAAa;IACxE,MAAMQ,OAAOC,KAAKC,SAAS,CAACV;IAC5B,MAAMW,YAAYP,KAAKQ,GAAG,CAAC,QAAQR,KAAKS,GAAG,CAAC,KAAKC,OAAOP,SAASI,SAAS,KAAK;IAC/E,MAAMI,UAAUX,KAAKQ,GAAG,CAAC,GAAGR,KAAKS,GAAG,CAAC,GAAGT,KAAKC,KAAK,CAACS,OAAOP,SAASQ,OAAO,KAAK;IAC/E,IAAIC,YAAY;IAChB,IAAIC;IACJ,IAAIC,QAAQ;IACZ,IAAK,IAAIC,UAAU,GAAGA,WAAWJ,SAASI,WAAW,EAAG;QACtD,IAAIA,UAAU,GAAG;YACf,MAAM1B,MAAMD,eAAe,CAACY,KAAKQ,GAAG,CAACO,UAAU,GAAG3B,gBAAgB4B,MAAM,GAAG,GAAG;QAChF;QACAF,SAAS;QACT,MAAMG,UAAkC;YAAE,gBAAgB;QAAmB;QAC7E,KAAK,MAAMC,UAAUf,SAASc,OAAO,IAAI,EAAE,CAAE;YAC3C,IAAIC,QAAQC,OAAOD,OAAOE,KAAK,EAAE;gBAC/BH,OAAO,CAACC,OAAOC,GAAG,CAAC,GAAGD,OAAOE,KAAK;YACpC;QACF;QACA,IAAIjB,SAASkB,aAAa,EAAE;YAC1BJ,OAAO,CAAC,mBAAmB,GAAG,MAAMvB,gBAAgBS,SAASkB,aAAa,EAAEjB;QAC9E;QACA,MAAMkB,aAAa,IAAIC;QACvB,MAAMC,QAAQ/B,WAAW,IAAM6B,WAAWG,KAAK,IAAIlB;QACnD,IAAI;YACF,MAAMmB,WAAW,MAAMC,MAAMC,OAAOzB,SAAS0B,GAAG,IAAI,KAAK;gBACvDjC,MAAMQ;gBACNa;gBACAa,QAAQ3B,SAAS2B,MAAM,IAAI;gBAC3BC,QAAQT,WAAWS,MAAM;YAC3B;YACAlB,aAAaa,SAASM,MAAM;YAC5B,IAAIN,SAASO,EAAE,EAAE;gBACf,OAAO;oBAAEA,IAAI;oBAAMD,QAAQN,SAASM,MAAM;oBAAElB;gBAAM;YACpD;YACAF,YAAY,CAAC,iBAAiB,EAAEc,SAASM,MAAM,CAAC,CAAC,CAAC;QACpD,EAAE,OAAOE,OAAO;YACdrB,aAAasB;YACbvB,YAAYU,WAAWS,MAAM,CAACK,OAAO,GACjC,CAAC,wBAAwB,EAAE7B,UAAU,IAAI,CAAC,GAC1C2B,iBAAiBG,QACfH,MAAMI,OAAO,GACbV,OAAOM;QACf,SAAU;YACRK,aAAaf;QACf;IACF;IACA,OAAO;QAAEU,OAAOtB;QAAWqB,IAAI;QAAOD,QAAQnB;QAAYC;IAAM;AAClE"}
1
+ {"version":3,"sources":["../../src/delivery/webhook.ts"],"sourcesContent":["import { hmacSha256Base64, hmacSha256Hex, standardWebhooksKey } from '../security/crypto.js'\n\nexport type WebhookSettings = {\n /**\n * Headers applied under the admin `headers` (e.g. resolved from the plugin config). A later\n * header replaces an earlier one of the same name, in any case.\n */\n configHeaders?: null | Record<string, string>\n /** The webhook's own (admin) secret headers. */\n headers?: Array<{ key?: null | string; value?: null | string }> | null\n method?: 'PATCH' | 'POST' | 'PUT' | null\n retries?: null | number\n /** The secret to sign with, already resolved. Empty: the request is sent unsigned. */\n signingSecret?: null | string\n timeoutMs?: null | number\n url?: null | string\n /**\n * The Standard Webhooks `webhook-id`, the same on every try. Missing: `msg_` plus a random id\n * made once per call. Characters outside `[A-Za-z0-9_]` become `_`.\n */\n webhookId?: null | string\n}\n\nexport type WebhookResult = { error?: string; ok: boolean; status?: number; tries: number }\n\n/** Pauses between tries: 0.5 s, 1 s, 2 s. */\nexport const RETRY_PAUSES_MS = [500, 1000, 2000]\n\nconst sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))\n\n/** `X-Form-Signature` value: `t=<unix seconds>,v1=<hex HMAC-SHA256 of \"<t>.<body>\">`. */\nexport async function signWebhookBody(secret: string, body: string, now = Date.now()): Promise<string> {\n const t = Math.floor(now / 1000)\n return `t=${t},v1=${await hmacSha256Hex(secret, `${t}.${body}`)}`\n}\n\n/** `msg_<submissionId>_<outputId>` when both exist, else `msg_` plus a random id; only `[A-Za-z0-9_]`. */\nexport function webhookMessageId(submissionId?: null | number | string, outputId?: null | string): string {\n const raw =\n submissionId !== undefined && submissionId !== null && submissionId !== '' && outputId\n ? `msg_${submissionId}_${outputId}`\n : `msg_${crypto.randomUUID().replace(/-/g, '')}`\n return raw.replace(/\\W/g, '_')\n}\n\n/**\n * Request headers in order, a later one replacing an earlier one whose name matches in any case:\n * `Content-Type`, then each group in turn.\n */\nfunction mergeHeaders(...groups: Array<Array<[string, string]>>): Record<string, string> {\n const merged = new Map<string, [string, string]>([['content-type', ['Content-Type', 'application/json']]])\n for (const group of groups) {\n for (const [key, value] of group) {\n merged.set(key.toLowerCase(), [key, value])\n }\n }\n return Object.fromEntries(merged.values())\n}\n\n/**\n * Sends one webhook: JSON body, config headers under the secret headers, an optional signature\n * (given resolved), a timeout per try (default 8 s) and up to 3 retries with growing pauses. A\n * non-2xx answer or a timeout is a failed try.\n */\nexport async function sendWebhook(settings: WebhookSettings, body: unknown): Promise<WebhookResult> {\n const json = JSON.stringify(body)\n const timeoutMs = Math.min(30_000, Math.max(500, Number(settings.timeoutMs) || 8000))\n const retries = Math.min(3, Math.max(0, Math.floor(Number(settings.retries) || 0)))\n const configHeaders = Object.entries(settings.configHeaders || {}).filter(\n (entry): entry is [string, string] => Boolean(entry[0]) && typeof entry[1] === 'string',\n )\n const adminHeaders = (settings.headers || []).flatMap((header): Array<[string, string]> =>\n header?.key && header.value ? [[header.key, header.value]] : [],\n )\n const secret = settings.signingSecret || ''\n const webhookId = settings.webhookId ? settings.webhookId.replace(/\\W/g, '_') : webhookMessageId()\n const key = secret ? standardWebhooksKey(secret) : null\n let lastError = ''\n let lastStatus: number | undefined\n let tries = 0\n for (let attempt = 0; attempt <= retries; attempt += 1) {\n if (attempt > 0) {\n await sleep(RETRY_PAUSES_MS[Math.min(attempt - 1, RETRY_PAUSES_MS.length - 1)])\n }\n tries += 1\n // Signature headers come last: neither config nor admin headers can replace them.\n const signature: Array<[string, string]> = [['webhook-id', webhookId]]\n if (secret && key) {\n const now = Date.now()\n const t = Math.floor(now / 1000)\n signature.push(\n ['X-Form-Signature', await signWebhookBody(secret, json, now)],\n ['webhook-timestamp', String(t)],\n ['webhook-signature', `v1,${await hmacSha256Base64(key, `${webhookId}.${t}.${json}`)}`],\n )\n }\n const headers = mergeHeaders(configHeaders, adminHeaders, signature)\n const controller = new AbortController()\n const timer = setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(String(settings.url || ''), {\n body: json,\n headers,\n method: settings.method || 'POST',\n signal: controller.signal,\n })\n lastStatus = response.status\n if (response.ok) {\n return { ok: true, status: response.status, tries }\n }\n lastError = `Webhook answered ${response.status}.`\n } catch (error) {\n lastStatus = undefined\n lastError = controller.signal.aborted\n ? `Webhook timed out after ${timeoutMs} ms.`\n : error instanceof Error\n ? error.message\n : String(error)\n } finally {\n clearTimeout(timer)\n }\n }\n return { error: lastError, ok: false, status: lastStatus, tries }\n}\n"],"names":["hmacSha256Base64","hmacSha256Hex","standardWebhooksKey","RETRY_PAUSES_MS","sleep","ms","Promise","resolve","setTimeout","signWebhookBody","secret","body","now","Date","t","Math","floor","webhookMessageId","submissionId","outputId","raw","undefined","crypto","randomUUID","replace","mergeHeaders","groups","merged","Map","group","key","value","set","toLowerCase","Object","fromEntries","values","sendWebhook","settings","json","JSON","stringify","timeoutMs","min","max","Number","retries","configHeaders","entries","filter","entry","Boolean","adminHeaders","headers","flatMap","header","signingSecret","webhookId","lastError","lastStatus","tries","attempt","length","signature","push","String","controller","AbortController","timer","abort","response","fetch","url","method","signal","status","ok","error","aborted","Error","message","clearTimeout"],"mappings":"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"}
package/dist/index.js CHANGED
@@ -52,6 +52,12 @@ export function resolveOptions(options = {}) {
52
52
  fieldName: options.turnstile?.fieldName || 'cf-turnstile-response',
53
53
  secretKey: options.turnstile?.secretKey || ''
54
54
  },
55
+ webhooks: {
56
+ headers: options.webhooks?.headers,
57
+ requireSignature: options.webhooks?.requireSignature === true,
58
+ secretsInAdmin: options.webhooks?.secretsInAdmin !== false,
59
+ signingSecret: options.webhooks?.signingSecret
60
+ },
55
61
  whatsapp: options.whatsapp,
56
62
  words: {
57
63
  ...DEFAULT_WORDS,
package/dist/index.js.map CHANGED
@@ -1 +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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"}
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 webhooks: {\n headers: options.webhooks?.headers,\n requireSignature: options.webhooks?.requireSignature === true,\n secretsInAdmin: options.webhooks?.secretsInAdmin !== false,\n signingSecret: options.webhooks?.signingSecret,\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","webhooks","headers","requireSignature","secretsInAdmin","signingSecret","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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"}
@@ -1,2 +1,9 @@
1
1
  export declare function sha256Hex(text: string): Promise<string>;
2
2
  export declare function hmacSha256Hex(secret: string, text: string): Promise<string>;
3
+ /** Base64 HMAC-SHA256 of `text` keyed by raw bytes. */
4
+ export declare function hmacSha256Base64(key: Uint8Array, text: string): Promise<string>;
5
+ /**
6
+ * The Standard Webhooks HMAC key: the base64-decoded bytes after the prefix of a `whsec_` secret,
7
+ * else the secret's UTF-8 bytes.
8
+ */
9
+ export declare function standardWebhooksKey(secret: string): Uint8Array;
@@ -13,5 +13,42 @@ export async function hmacSha256Hex(secret, text) {
13
13
  ]);
14
14
  return toHex(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(text)));
15
15
  }
16
+ function bytesToBase64(bytes) {
17
+ let binary = '';
18
+ for (const byte of bytes){
19
+ binary += String.fromCharCode(byte);
20
+ }
21
+ return btoa(binary);
22
+ }
23
+ function base64ToBytes(base64) {
24
+ const binary = atob(base64);
25
+ const bytes = new Uint8Array(binary.length);
26
+ for(let i = 0; i < binary.length; i += 1){
27
+ bytes[i] = binary.charCodeAt(i);
28
+ }
29
+ return bytes;
30
+ }
31
+ /** Base64 HMAC-SHA256 of `text` keyed by raw bytes. */ export async function hmacSha256Base64(key, text) {
32
+ const cryptoKey = await crypto.subtle.importKey('raw', key, {
33
+ name: 'HMAC',
34
+ hash: 'SHA-256'
35
+ }, false, [
36
+ 'sign'
37
+ ]);
38
+ return bytesToBase64(new Uint8Array(await crypto.subtle.sign('HMAC', cryptoKey, new TextEncoder().encode(text))));
39
+ }
40
+ /**
41
+ * The Standard Webhooks HMAC key: the base64-decoded bytes after the prefix of a `whsec_` secret,
42
+ * else the secret's UTF-8 bytes.
43
+ */ export function standardWebhooksKey(secret) {
44
+ if (secret.startsWith('whsec_')) {
45
+ try {
46
+ return base64ToBytes(secret.slice('whsec_'.length));
47
+ } catch {
48
+ throw new Error('The signing secret starts with whsec_ but the rest is not valid base64.');
49
+ }
50
+ }
51
+ return new TextEncoder().encode(secret);
52
+ }
16
53
 
17
54
  //# sourceMappingURL=crypto.js.map
@@ -1 +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"}
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\nfunction bytesToBase64(bytes: Uint8Array): string {\n let binary = ''\n for (const byte of bytes) {\n binary += String.fromCharCode(byte)\n }\n return btoa(binary)\n}\n\nfunction base64ToBytes(base64: string): Uint8Array {\n const binary = atob(base64)\n const bytes = new Uint8Array(binary.length)\n for (let i = 0; i < binary.length; i += 1) {\n bytes[i] = binary.charCodeAt(i)\n }\n return bytes\n}\n\n/** Base64 HMAC-SHA256 of `text` keyed by raw bytes. */\nexport async function hmacSha256Base64(key: Uint8Array, text: string): Promise<string> {\n const cryptoKey = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])\n return bytesToBase64(new Uint8Array(await crypto.subtle.sign('HMAC', cryptoKey, new TextEncoder().encode(text))))\n}\n\n/**\n * The Standard Webhooks HMAC key: the base64-decoded bytes after the prefix of a `whsec_` secret,\n * else the secret's UTF-8 bytes.\n */\nexport function standardWebhooksKey(secret: string): Uint8Array {\n if (secret.startsWith('whsec_')) {\n try {\n return base64ToBytes(secret.slice('whsec_'.length))\n } catch {\n throw new Error('The signing secret starts with whsec_ but the rest is not valid base64.')\n }\n }\n return new TextEncoder().encode(secret)\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","bytesToBase64","bytes","binary","byte","String","fromCharCode","btoa","base64ToBytes","base64","atob","length","i","charCodeAt","hmacSha256Base64","cryptoKey","standardWebhooksKey","startsWith","slice","Error"],"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;AAEA,SAASa,cAAcC,KAAiB;IACtC,IAAIC,SAAS;IACb,KAAK,MAAMC,QAAQF,MAAO;QACxBC,UAAUE,OAAOC,YAAY,CAACF;IAChC;IACA,OAAOG,KAAKJ;AACd;AAEA,SAASK,cAAcC,MAAc;IACnC,MAAMN,SAASO,KAAKD;IACpB,MAAMP,QAAQ,IAAIrB,WAAWsB,OAAOQ,MAAM;IAC1C,IAAK,IAAIC,IAAI,GAAGA,IAAIT,OAAOQ,MAAM,EAAEC,KAAK,EAAG;QACzCV,KAAK,CAACU,EAAE,GAAGT,OAAOU,UAAU,CAACD;IAC/B;IACA,OAAOV;AACT;AAEA,qDAAqD,GACrD,OAAO,eAAeY,iBAAiBlB,GAAe,EAAER,IAAY;IAClE,MAAM2B,YAAY,MAAM1B,OAAOC,MAAM,CAACO,SAAS,CAAC,OAAOD,KAAK;QAAEE,MAAM;QAAQC,MAAM;IAAU,GAAG,OAAO;QAAC;KAAO;IAC9G,OAAOE,cAAc,IAAIpB,WAAW,MAAMQ,OAAOC,MAAM,CAACU,IAAI,CAAC,QAAQe,WAAW,IAAIvB,cAAcC,MAAM,CAACL;AAC3G;AAEA;;;CAGC,GACD,OAAO,SAAS4B,oBAAoBrB,MAAc;IAChD,IAAIA,OAAOsB,UAAU,CAAC,WAAW;QAC/B,IAAI;YACF,OAAOT,cAAcb,OAAOuB,KAAK,CAAC,SAASP,MAAM;QACnD,EAAE,OAAM;YACN,MAAM,IAAIQ,MAAM;QAClB;IACF;IACA,OAAO,IAAI3B,cAAcC,MAAM,CAACE;AAClC"}
package/dist/types.d.ts CHANGED
@@ -20,8 +20,51 @@ export type OptionSource = {
20
20
  /** Field stored as the answer. */
21
21
  valueField: string;
22
22
  };
23
+ /** What a `webhooks.signingSecret` or `webhooks.headers` function receives for each webhook it is asked about. */
24
+ export type WebhookConfigArgs = {
25
+ form: PublicForm;
26
+ req: PayloadRequest;
27
+ webhook: {
28
+ id: null | string;
29
+ label: null | string;
30
+ url: string;
31
+ };
32
+ };
33
+ /** A value, or a (possibly async) function of the form, request and webhook returning one. */
34
+ export type WebhookConfigValue<T> = ((args: WebhookConfigArgs) => Promise<T | undefined> | T | undefined) | T;
35
+ export type WebhookOptions = {
36
+ /**
37
+ * Request headers for every webhook, e.g. an API key from an env var. The webhook's own admin
38
+ * secret headers replace a config header of the same name (any case); neither can replace
39
+ * `Content-Type`'s place first or the signature headers.
40
+ */
41
+ headers?: WebhookConfigValue<Record<string, string>>;
42
+ /**
43
+ * `true` refuses to send a webhook that has no signing secret from either source: no request is
44
+ * made and its delivery is logged as failed. Default `false`: it is sent unsigned, with one
45
+ * warning per webhook per server process.
46
+ */
47
+ requireSignature?: boolean;
48
+ /**
49
+ * Default `true`: each webhook row has the admin-only `signingSecret` and secret `headers`
50
+ * fields, as in 0.1.0. `false` leaves both fields out of the forms collection, so secrets come
51
+ * only from this config and none is stored in the database. Turning it off drops the column
52
+ * and the headers table at the app's next migration; stored values are lost.
53
+ */
54
+ secretsInAdmin?: boolean;
55
+ /**
56
+ * Signs any webhook whose admin signing secret is empty (the admin secret wins). A string, or a
57
+ * function returning one, e.g. per webhook label. A `whsec_…` secret also keys the Standard
58
+ * Webhooks signature with its decoded bytes.
59
+ */
60
+ signingSecret?: WebhookConfigValue<string>;
61
+ };
23
62
  export type FormsPluginOptions = {
24
- /** Who may read and write webhook secrets, update or delete submissions and resend. Default: any logged-in user. */
63
+ /**
64
+ * Who may read and write webhook secrets in the admin, update or delete submissions and resend.
65
+ * Default: any logged-in user. Prefer `webhooks.signingSecret` / `webhooks.headers` from env
66
+ * vars (with `webhooks.secretsInAdmin: false`) over secrets stored in forms.
67
+ */
25
68
  access?: {
26
69
  isAdmin?: (req: PayloadRequest) => boolean;
27
70
  };
@@ -89,6 +132,8 @@ export type FormsPluginOptions = {
89
132
  fieldName?: string;
90
133
  secretKey?: string;
91
134
  };
135
+ /** Webhook secrets and headers from the config, and whether signing is required. */
136
+ webhooks?: WebhookOptions;
92
137
  whatsapp?: {
93
138
  /** Number for forms that have none. */
94
139
  number?: (args: {
@@ -129,5 +174,9 @@ export type ResolvedOptions = {
129
174
  fieldName: string;
130
175
  secretKey: string;
131
176
  };
177
+ webhooks: {
178
+ requireSignature: boolean;
179
+ secretsInAdmin: boolean;
180
+ } & Pick<WebhookOptions, 'headers' | 'signingSecret'>;
132
181
  words: FormWords;
133
182
  } & Pick<FormsPluginOptions, 'beforeEmail' | 'delivery' | 'optionSources' | 'whatsapp'>;
package/dist/types.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/types.ts"],"sourcesContent":["import type {\n Block,\n CollectionConfig,\n CollectionSlug,\n Field,\n PayloadRequest,\n SendEmailOptions,\n} from 'payload'\n\nimport type { Answers, FieldTypeChecks, FormWords, PublicForm } from './shared/types.js'\n\n/** A new field type: its block plus the checks the server runs for it. */\nexport type FieldTypeDefinition = {\n /** The admin block. The plugin adds the common input fields (key, required, words, rules…). */\n block: Block\n} & FieldTypeChecks\n\nexport type CollectionOverride = {\n /** Receives the plugin's fields and returns the field list to use. */\n fields?: (args: { defaultFields: Field[] }) => Field[]\n} & Partial<Omit<CollectionConfig, 'fields'>>\n\nexport type OptionSource = {\n /** Extra fields of each item carried as option attributes (rules and tokens read them). */\n attributes?: string[]\n collection: CollectionSlug\n /** Field shown to the visitor. */\n labelField: string\n /** Field stored as the answer. */\n valueField: string\n}\n\nexport type FormsPluginOptions = {\n /** Who may read and write webhook secrets, update or delete submissions and resend. Default: any logged-in user. */\n access?: { isAdmin?: (req: PayloadRequest) => boolean }\n /** Overrides for the attachments collection (when the plugin creates it). */\n attachmentOverrides?: CollectionOverride\n /** Private uploads for file fields. `false`: no attachments collection (file uploads can't be turned on). */\n attachments?:\n | {\n /** Use the app's own upload collection instead of creating one; the plugin adds its link fields. */\n collection?: CollectionSlug\n /** Largest file in bytes. Default 10 MB. */\n maxFileSize?: number\n /** Largest total per submission in bytes. Default 25 MB. */\n maxTotalSize?: number\n /** Allowed MIME types. Default: PDF, JPEG, PNG, WebP, text, CSV, Word. */\n mimeTypes?: string[]\n }\n | false\n /** Gets each email before it is sent (wrap it in a layout, add headers…) and returns the one to send. */\n beforeEmail?: (\n message: SendEmailOptions,\n ctx: { answers: Answers; form: PublicForm; req: PayloadRequest },\n ) => Promise<SendEmailOptions> | SendEmailOptions\n delivery?: {\n /**\n * Runs email and webhook delivery after the reply, e.g. `(task) => after(task)` (Next.js) or\n * `(task) => ctx.waitUntil(task())` (Workers). Default: delivery runs before the reply.\n */\n schedule?: (task: () => Promise<void>) => void\n }\n /** Default `true`. `false` returns the config untouched. */\n enabled?: boolean\n /**\n * Field-type registry keyed by type: `true`/`false` turns a built-in type on or off, a partial\n * block is merged into the built-in one, a full definition adds a new type. `fileUpload` is off\n * unless turned on.\n */\n fields?: Record<string, boolean | FieldTypeDefinition | Partial<Block>>\n /** Overrides for the forms collection. */\n formOverrides?: CollectionOverride\n /** Name of the honeypot field in a submission. Default `_hp`. */\n honeypotField?: string\n /** What a submission keeps about the request. Default: IP off, user agent on. */\n metadata?: { ip?: boolean; userAgent?: boolean }\n /** Collections whose items can be the choices of select, radio and checkbox-group fields. */\n optionSources?: Record<string, OptionSource>\n /** Per form and visitor. Default 5 per 600 s. `false` turns it off. */\n rateLimit?: { max?: number; windowSeconds?: number } | false\n /** Collection slugs. Default `custom-forms`, `custom-form-submissions`, `custom-form-attachments`. */\n slugs?: { attachments?: string; forms?: string; submissions?: string }\n /** Overrides for the submissions collection. */\n submissionOverrides?: CollectionOverride\n /** Cloudflare Turnstile checked on the server. On with no secret refuses every submission. */\n turnstile?: { enabled?: boolean; fieldName?: string; secretKey?: string }\n whatsapp?: {\n /** Number for forms that have none. */\n number?: (args: {\n form: PublicForm\n req: PayloadRequest\n }) => Promise<string | undefined> | string | undefined\n }\n /** Default words for new forms, merged over `DEFAULT_WORDS`. */\n words?: Partial<FormWords>\n}\n\n/** The options with every default applied. */\nexport type ResolvedOptions = {\n attachments: {\n collection: string\n createCollection: boolean\n maxFileSize: number\n maxTotalSize: number\n mimeTypes: string[]\n } | null\n fieldTypes: Record<string, FieldTypeChecks>\n honeypotField: string\n isAdmin: (req: PayloadRequest) => boolean\n metadata: { ip: boolean; userAgent: boolean }\n rateLimit: { max: number; windowSeconds: number } | null\n slugs: { attachments: string; forms: string; submissions: string }\n turnstile: { enabled: boolean; fieldName: string; secretKey: string }\n words: FormWords\n} & Pick<FormsPluginOptions, 'beforeEmail' | 'delivery' | 'optionSources' | 'whatsapp'>\n"],"names":[],"mappings":"AAiGA,4CAA4C,GAC5C,WAgBuF"}
1
+ {"version":3,"sources":["../src/types.ts"],"sourcesContent":["import type {\n Block,\n CollectionConfig,\n CollectionSlug,\n Field,\n PayloadRequest,\n SendEmailOptions,\n} from 'payload'\n\nimport type { Answers, FieldTypeChecks, FormWords, PublicForm } from './shared/types.js'\n\n/** A new field type: its block plus the checks the server runs for it. */\nexport type FieldTypeDefinition = {\n /** The admin block. The plugin adds the common input fields (key, required, words, rules…). */\n block: Block\n} & FieldTypeChecks\n\nexport type CollectionOverride = {\n /** Receives the plugin's fields and returns the field list to use. */\n fields?: (args: { defaultFields: Field[] }) => Field[]\n} & Partial<Omit<CollectionConfig, 'fields'>>\n\nexport type OptionSource = {\n /** Extra fields of each item carried as option attributes (rules and tokens read them). */\n attributes?: string[]\n collection: CollectionSlug\n /** Field shown to the visitor. */\n labelField: string\n /** Field stored as the answer. */\n valueField: string\n}\n\n/** What a `webhooks.signingSecret` or `webhooks.headers` function receives for each webhook it is asked about. */\nexport type WebhookConfigArgs = {\n form: PublicForm\n req: PayloadRequest\n webhook: { id: null | string; label: null | string; url: string }\n}\n\n/** A value, or a (possibly async) function of the form, request and webhook returning one. */\nexport type WebhookConfigValue<T> =\n | ((args: WebhookConfigArgs) => Promise<T | undefined> | T | undefined)\n | T\n\nexport type WebhookOptions = {\n /**\n * Request headers for every webhook, e.g. an API key from an env var. The webhook's own admin\n * secret headers replace a config header of the same name (any case); neither can replace\n * `Content-Type`'s place first or the signature headers.\n */\n headers?: WebhookConfigValue<Record<string, string>>\n /**\n * `true` refuses to send a webhook that has no signing secret from either source: no request is\n * made and its delivery is logged as failed. Default `false`: it is sent unsigned, with one\n * warning per webhook per server process.\n */\n requireSignature?: boolean\n /**\n * Default `true`: each webhook row has the admin-only `signingSecret` and secret `headers`\n * fields, as in 0.1.0. `false` leaves both fields out of the forms collection, so secrets come\n * only from this config and none is stored in the database. Turning it off drops the column\n * and the headers table at the app's next migration; stored values are lost.\n */\n secretsInAdmin?: boolean\n /**\n * Signs any webhook whose admin signing secret is empty (the admin secret wins). A string, or a\n * function returning one, e.g. per webhook label. A `whsec_…` secret also keys the Standard\n * Webhooks signature with its decoded bytes.\n */\n signingSecret?: WebhookConfigValue<string>\n}\n\nexport type FormsPluginOptions = {\n /**\n * Who may read and write webhook secrets in the admin, update or delete submissions and resend.\n * Default: any logged-in user. Prefer `webhooks.signingSecret` / `webhooks.headers` from env\n * vars (with `webhooks.secretsInAdmin: false`) over secrets stored in forms.\n */\n access?: { isAdmin?: (req: PayloadRequest) => boolean }\n /** Overrides for the attachments collection (when the plugin creates it). */\n attachmentOverrides?: CollectionOverride\n /** Private uploads for file fields. `false`: no attachments collection (file uploads can't be turned on). */\n attachments?:\n | {\n /** Use the app's own upload collection instead of creating one; the plugin adds its link fields. */\n collection?: CollectionSlug\n /** Largest file in bytes. Default 10 MB. */\n maxFileSize?: number\n /** Largest total per submission in bytes. Default 25 MB. */\n maxTotalSize?: number\n /** Allowed MIME types. Default: PDF, JPEG, PNG, WebP, text, CSV, Word. */\n mimeTypes?: string[]\n }\n | false\n /** Gets each email before it is sent (wrap it in a layout, add headers…) and returns the one to send. */\n beforeEmail?: (\n message: SendEmailOptions,\n ctx: { answers: Answers; form: PublicForm; req: PayloadRequest },\n ) => Promise<SendEmailOptions> | SendEmailOptions\n delivery?: {\n /**\n * Runs email and webhook delivery after the reply, e.g. `(task) => after(task)` (Next.js) or\n * `(task) => ctx.waitUntil(task())` (Workers). Default: delivery runs before the reply.\n */\n schedule?: (task: () => Promise<void>) => void\n }\n /** Default `true`. `false` returns the config untouched. */\n enabled?: boolean\n /**\n * Field-type registry keyed by type: `true`/`false` turns a built-in type on or off, a partial\n * block is merged into the built-in one, a full definition adds a new type. `fileUpload` is off\n * unless turned on.\n */\n fields?: Record<string, boolean | FieldTypeDefinition | Partial<Block>>\n /** Overrides for the forms collection. */\n formOverrides?: CollectionOverride\n /** Name of the honeypot field in a submission. Default `_hp`. */\n honeypotField?: string\n /** What a submission keeps about the request. Default: IP off, user agent on. */\n metadata?: { ip?: boolean; userAgent?: boolean }\n /** Collections whose items can be the choices of select, radio and checkbox-group fields. */\n optionSources?: Record<string, OptionSource>\n /** Per form and visitor. Default 5 per 600 s. `false` turns it off. */\n rateLimit?: { max?: number; windowSeconds?: number } | false\n /** Collection slugs. Default `custom-forms`, `custom-form-submissions`, `custom-form-attachments`. */\n slugs?: { attachments?: string; forms?: string; submissions?: string }\n /** Overrides for the submissions collection. */\n submissionOverrides?: CollectionOverride\n /** Cloudflare Turnstile checked on the server. On with no secret refuses every submission. */\n turnstile?: { enabled?: boolean; fieldName?: string; secretKey?: string }\n /** Webhook secrets and headers from the config, and whether signing is required. */\n webhooks?: WebhookOptions\n whatsapp?: {\n /** Number for forms that have none. */\n number?: (args: {\n form: PublicForm\n req: PayloadRequest\n }) => Promise<string | undefined> | string | undefined\n }\n /** Default words for new forms, merged over `DEFAULT_WORDS`. */\n words?: Partial<FormWords>\n}\n\n/** The options with every default applied. */\nexport type ResolvedOptions = {\n attachments: {\n collection: string\n createCollection: boolean\n maxFileSize: number\n maxTotalSize: number\n mimeTypes: string[]\n } | null\n fieldTypes: Record<string, FieldTypeChecks>\n honeypotField: string\n isAdmin: (req: PayloadRequest) => boolean\n metadata: { ip: boolean; userAgent: boolean }\n rateLimit: { max: number; windowSeconds: number } | null\n slugs: { attachments: string; forms: string; submissions: string }\n turnstile: { enabled: boolean; fieldName: string; secretKey: string }\n webhooks: {\n requireSignature: boolean\n secretsInAdmin: boolean\n } & Pick<WebhookOptions, 'headers' | 'signingSecret'>\n words: FormWords\n} & Pick<FormsPluginOptions, 'beforeEmail' | 'delivery' | 'optionSources' | 'whatsapp'>\n"],"names":[],"mappings":"AA+IA,4CAA4C,GAC5C,WAoBuF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payload-dynamic-forms",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Forms for Payload CMS 3: sections, steps, show/hide rules checked on the server, spam checks, attachments, email, WhatsApp and webhook delivery. No visitor UI.",
5
5
  "license": "MIT",
6
6
  "author": "Kairos Software Solutions",
@@ -83,6 +83,7 @@
83
83
  "react-dom": "19.2.6",
84
84
  "rimraf": "3.0.2",
85
85
  "sort-package-json": "^2.10.0",
86
+ "standardwebhooks": "1.1.1",
86
87
  "typescript": "5.7.3",
87
88
  "vite-tsconfig-paths": "6.0.5",
88
89
  "vitest": "4.0.18",