From 05b3dc8cb1b8f9846f63dc9d0db5c1ac23eeb44e Mon Sep 17 00:00:00 2001 From: rasm-its Date: Fri, 21 Aug 2026 19:03:14 +0200 Subject: [PATCH] security headers, notifications, GDPR consent, masked fields --- dist/exports/client.js | 1 + dist/exports/client.js.map | 2 +- dist/globals/Notifications/fields.js | 82 +++++++++++++ dist/globals/Notifications/fields.js.map | 1 + dist/globals/Notifications/index.js | 17 +++ dist/globals/Notifications/index.js.map | 1 + .../components/MaskedField.js | 45 +++++++ .../components/MaskedField.js.map | 1 + dist/globals/SiteIntegrations/fields/smtp.js | 6 +- .../SiteIntegrations/fields/smtp.js.map | 2 +- .../SiteIntegrations/fields/storage.js | 6 +- .../SiteIntegrations/fields/storage.js.map | 2 +- .../SiteIntegrations/fields/turnstile.js | 6 +- .../SiteIntegrations/fields/turnstile.js.map | 2 +- dist/modules/forms/submitForm.js | 11 +- dist/modules/forms/submitForm.js.map | 2 +- dist/modules/forms/types.js.map | 2 +- dist/modules/forms/validateSubmission.js | 24 +++- dist/modules/forms/validateSubmission.js.map | 2 +- dist/modules/notifications/defaults.js | 17 +++ dist/modules/notifications/defaults.js.map | 1 + .../notifications/getNotificationTexts.js | 27 +++++ .../notifications/getNotificationTexts.js.map | 1 + dist/modules/notifications/index.js | 5 + dist/modules/notifications/index.js.map | 1 + .../notifications/resolveFormMessage.js | 35 ++++++ .../notifications/resolveFormMessage.js.map | 1 + dist/modules/notifications/types.js | 6 + dist/modules/notifications/types.js.map | 1 + .../security/buildSecurityHeaders.d.ts | 70 +++++++++++ dist/modules/security/buildSecurityHeaders.js | 81 +++++++++++++ .../security/buildSecurityHeaders.js.map | 1 + dist/modules/security/index.d.ts | 2 + dist/modules/security/index.js | 3 + dist/modules/security/index.js.map | 1 + src/modules/security/buildSecurityHeaders.ts | 113 ++++++++++++++++++ src/modules/security/index.ts | 2 + 37 files changed, 569 insertions(+), 14 deletions(-) create mode 100644 dist/globals/Notifications/fields.js create mode 100644 dist/globals/Notifications/fields.js.map create mode 100644 dist/globals/Notifications/index.js create mode 100644 dist/globals/Notifications/index.js.map create mode 100644 dist/globals/SiteIntegrations/components/MaskedField.js create mode 100644 dist/globals/SiteIntegrations/components/MaskedField.js.map create mode 100644 dist/modules/notifications/defaults.js create mode 100644 dist/modules/notifications/defaults.js.map create mode 100644 dist/modules/notifications/getNotificationTexts.js create mode 100644 dist/modules/notifications/getNotificationTexts.js.map create mode 100644 dist/modules/notifications/index.js create mode 100644 dist/modules/notifications/index.js.map create mode 100644 dist/modules/notifications/resolveFormMessage.js create mode 100644 dist/modules/notifications/resolveFormMessage.js.map create mode 100644 dist/modules/notifications/types.js create mode 100644 dist/modules/notifications/types.js.map create mode 100644 dist/modules/security/buildSecurityHeaders.d.ts create mode 100644 dist/modules/security/buildSecurityHeaders.js create mode 100644 dist/modules/security/buildSecurityHeaders.js.map create mode 100644 dist/modules/security/index.d.ts create mode 100644 dist/modules/security/index.js create mode 100644 dist/modules/security/index.js.map create mode 100644 src/modules/security/buildSecurityHeaders.ts create mode 100644 src/modules/security/index.ts diff --git a/dist/exports/client.js b/dist/exports/client.js index df1c2ee..650fbca 100644 --- a/dist/exports/client.js +++ b/dist/exports/client.js @@ -1,4 +1,5 @@ 'use client'; +export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js'; export { Analytics } from '../modules/analytics/client.js'; /** * Entry point: ipal-kit/client diff --git a/dist/exports/client.js.map b/dist/exports/client.js.map index 66254f1..b3442a3 100644 --- a/dist/exports/client.js.map +++ b/dist/exports/client.js.map @@ -1 +1 @@ -{"version":3,"sources":["../../src/exports/client.ts"],"sourcesContent":["'use client'\nexport { Analytics } from '../modules/analytics/client.js'\n/**\n * Entry point: ipal-kit/client\n *\n * Client-side ('use client') exports — React hooks, providers, and UI\n * components. Kept separate from the main entry so server bundles don't pull in\n * client-only code.\n */\nexport {\n ConsentProvider,\n CookieBanner,\n CookieButton,\n useConsent,\n useConsentContext,\n} from '../modules/consent/client.js'\nexport type { CookieBannerClassNames } from '../modules/consent/client.js'\nexport { Turnstile } from '../modules/turnstile/client.js'\nexport type { TurnstileProps } from '../modules/turnstile/client.js'\n"],"names":["Analytics","ConsentProvider","CookieBanner","CookieButton","useConsent","useConsentContext","Turnstile"],"mappings":"AAAA;AACA,SAASA,SAAS,QAAQ,iCAAgC;AAC1D;;;;;;CAMC,GACD,SACEC,eAAe,EACfC,YAAY,EACZC,YAAY,EACZC,UAAU,EACVC,iBAAiB,QACZ,+BAA8B;AAErC,SAASC,SAAS,QAAQ,iCAAgC"} \ No newline at end of file +{"version":3,"sources":["../../src/exports/client.ts"],"sourcesContent":["'use client'\nexport { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js'\nexport { Analytics } from '../modules/analytics/client.js'\n/**\n * Entry point: ipal-kit/client\n *\n * Client-side ('use client') exports — React hooks, providers, and UI\n * components. Kept separate from the main entry so server bundles don't pull in\n * client-only code.\n */\nexport {\n ConsentProvider,\n CookieBanner,\n CookieButton,\n useConsent,\n useConsentContext,\n} from '../modules/consent/client.js'\nexport type { CookieBannerClassNames } from '../modules/consent/client.js'\nexport { Turnstile } from '../modules/turnstile/client.js'\nexport type { TurnstileProps } from '../modules/turnstile/client.js'\n"],"names":["MaskedField","Analytics","ConsentProvider","CookieBanner","CookieButton","useConsent","useConsentContext","Turnstile"],"mappings":"AAAA;AACA,SAASA,WAAW,QAAQ,wDAAuD;AACnF,SAASC,SAAS,QAAQ,iCAAgC;AAC1D;;;;;;CAMC,GACD,SACEC,eAAe,EACfC,YAAY,EACZC,YAAY,EACZC,UAAU,EACVC,iBAAiB,QACZ,+BAA8B;AAErC,SAASC,SAAS,QAAQ,iCAAgC"} \ No newline at end of file diff --git a/dist/globals/Notifications/fields.js b/dist/globals/Notifications/fields.js new file mode 100644 index 0000000..3aef297 --- /dev/null +++ b/dist/globals/Notifications/fields.js @@ -0,0 +1,82 @@ +/** + * Fields for the Notifications global — localized user-facing texts for action + * results (form submission outcomes, and future contexts). Every text is + * localized: true so each language has its own value. Empty fields fall back to + * built-in English defaults (see modules/notifications/defaults). + * + * Grouped per context. `form` holds the outcomes of submitForm; more groups + * (e.g. `newsletter`, `system`) can be added the same way without touching + * consumers — getNotificationTexts resolves whatever exists, falling back + * per field. + */ export const notificationsFields = [ + { + name: 'form', + type: 'group', + admin: { + description: 'Messages shown after a form is submitted. Leave a field empty to use the built-in default.' + }, + fields: [ + { + name: 'success', + type: 'text', + admin: { + placeholder: 'Thank you — your message has been sent.' + }, + localized: true + }, + { + name: 'error', + type: 'text', + admin: { + placeholder: 'Something went wrong. Please try again later.' + }, + localized: true + }, + { + name: 'rateLimited', + type: 'text', + admin: { + placeholder: 'Too many attempts. Please wait a moment and try again.' + }, + localized: true + }, + { + name: 'turnstile', + type: 'text', + admin: { + placeholder: 'Captcha verification failed. Please try again.' + }, + localized: true + }, + { + name: 'validation', + type: 'text', + admin: { + description: 'Shown on a validation error. Use {field} to insert the offending field name.', + placeholder: 'Please check the {field} field and try again.' + }, + localized: true + }, + { + name: 'consent', + type: 'text', + admin: { + description: 'Shown when the GDPR consent checkbox is left unchecked.', + placeholder: 'Please accept the privacy policy to continue.' + }, + localized: true + }, + { + name: 'notFound', + type: 'text', + admin: { + placeholder: 'This form is no longer available.' + }, + localized: true + } + ], + label: 'Form messages' + } +]; + +//# sourceMappingURL=fields.js.map \ No newline at end of file diff --git a/dist/globals/Notifications/fields.js.map b/dist/globals/Notifications/fields.js.map new file mode 100644 index 0000000..32d8013 --- /dev/null +++ b/dist/globals/Notifications/fields.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/globals/Notifications/fields.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * Fields for the Notifications global — localized user-facing texts for action\n * results (form submission outcomes, and future contexts). Every text is\n * localized: true so each language has its own value. Empty fields fall back to\n * built-in English defaults (see modules/notifications/defaults).\n *\n * Grouped per context. `form` holds the outcomes of submitForm; more groups\n * (e.g. `newsletter`, `system`) can be added the same way without touching\n * consumers — getNotificationTexts resolves whatever exists, falling back\n * per field.\n */\nexport const notificationsFields: Field[] = [\n {\n name: 'form',\n type: 'group',\n admin: {\n description:\n 'Messages shown after a form is submitted. Leave a field empty to use the built-in default.',\n },\n fields: [\n {\n name: 'success',\n type: 'text',\n admin: { placeholder: 'Thank you — your message has been sent.' },\n localized: true,\n },\n {\n name: 'error',\n type: 'text',\n admin: { placeholder: 'Something went wrong. Please try again later.' },\n localized: true,\n },\n {\n name: 'rateLimited',\n type: 'text',\n admin: { placeholder: 'Too many attempts. Please wait a moment and try again.' },\n localized: true,\n },\n {\n name: 'turnstile',\n type: 'text',\n admin: { placeholder: 'Captcha verification failed. Please try again.' },\n localized: true,\n },\n {\n name: 'validation',\n type: 'text',\n admin: {\n description:\n 'Shown on a validation error. Use {field} to insert the offending field name.',\n placeholder: 'Please check the {field} field and try again.',\n },\n localized: true,\n },\n {\n name: 'consent',\n type: 'text',\n admin: {\n description: 'Shown when the GDPR consent checkbox is left unchecked.',\n placeholder: 'Please accept the privacy policy to continue.',\n },\n localized: true,\n },\n {\n name: 'notFound',\n type: 'text',\n admin: { placeholder: 'This form is no longer available.' },\n localized: true,\n },\n ],\n label: 'Form messages',\n },\n]\n"],"names":["notificationsFields","name","type","admin","description","fields","placeholder","localized","label"],"mappings":"AAEA;;;;;;;;;;CAUC,GACD,OAAO,MAAMA,sBAA+B;IAC1C;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aACE;QACJ;QACAC,QAAQ;YACN;gBACEJ,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEG,aAAa;gBAA0C;gBAChEC,WAAW;YACb;YACA;gBACEN,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEG,aAAa;gBAAgD;gBACtEC,WAAW;YACb;YACA;gBACEN,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEG,aAAa;gBAAyD;gBAC/EC,WAAW;YACb;YACA;gBACEN,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEG,aAAa;gBAAiD;gBACvEC,WAAW;YACb;YACA;gBACEN,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBACLC,aACE;oBACFE,aAAa;gBACf;gBACAC,WAAW;YACb;YACA;gBACEN,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBACLC,aAAa;oBACbE,aAAa;gBACf;gBACAC,WAAW;YACb;YACA;gBACEN,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEG,aAAa;gBAAoC;gBAC1DC,WAAW;YACb;SACD;QACDC,OAAO;IACT;CACD,CAAA"} \ No newline at end of file diff --git a/dist/globals/Notifications/index.js b/dist/globals/Notifications/index.js new file mode 100644 index 0000000..6f1dd75 --- /dev/null +++ b/dist/globals/Notifications/index.js @@ -0,0 +1,17 @@ +import { notificationsFields } from './fields.js'; +/** + * Builds the Notifications global — localized action-result texts. Readable by + * any authenticated panel user; server-side helpers read it with overrideAccess + * so the frontend can resolve texts without a session. + */ export function buildNotifications() { + return { + slug: 'notifications', + label: 'Notifications', + access: { + read: ()=>true + }, + fields: notificationsFields + }; +} + +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/globals/Notifications/index.js.map b/dist/globals/Notifications/index.js.map new file mode 100644 index 0000000..e09fb6d --- /dev/null +++ b/dist/globals/Notifications/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/globals/Notifications/index.ts"],"sourcesContent":["import type { GlobalConfig } from 'payload'\nimport { notificationsFields } from './fields.js'\n\n/**\n * Builds the Notifications global — localized action-result texts. Readable by\n * any authenticated panel user; server-side helpers read it with overrideAccess\n * so the frontend can resolve texts without a session.\n */\nexport function buildNotifications(): GlobalConfig {\n return {\n slug: 'notifications',\n label: 'Notifications',\n access: {\n read: () => true, // texts are public-facing (shown to end users)\n },\n fields: notificationsFields,\n }\n}\n"],"names":["notificationsFields","buildNotifications","slug","label","access","read","fields"],"mappings":"AACA,SAASA,mBAAmB,QAAQ,cAAa;AAEjD;;;;CAIC,GACD,OAAO,SAASC;IACd,OAAO;QACLC,MAAM;QACNC,OAAO;QACPC,QAAQ;YACNC,MAAM,IAAM;QACd;QACAC,QAAQN;IACV;AACF"} \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/components/MaskedField.js b/dist/globals/SiteIntegrations/components/MaskedField.js new file mode 100644 index 0000000..615da66 --- /dev/null +++ b/dist/globals/SiteIntegrations/components/MaskedField.js @@ -0,0 +1,45 @@ +'use client'; +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { useField } from '@payloadcms/ui'; +import { useState } from 'react'; +export const MaskedField = ({ field, path })=>{ + const { setValue, value } = useField({ + path + }); + const [revealed, setRevealed] = useState(false); + const label = typeof field?.label === 'string' ? field.label : field?.name ?? path; + return /*#__PURE__*/ _jsxs("div", { + className: "field-type text", + children: [ + /*#__PURE__*/ _jsx("label", { + className: "field-label", + children: label + }), + /*#__PURE__*/ _jsxs("div", { + style: { + display: 'flex', + gap: '.5rem' + }, + children: [ + /*#__PURE__*/ _jsx("input", { + autoComplete: "off", + onChange: (e)=>setValue(e.target.value), + style: { + flex: 1 + }, + type: revealed ? 'text' : 'password', + value: value ?? '' + }), + /*#__PURE__*/ _jsx("button", { + onClick: ()=>setRevealed((r)=>!r), + type: "button", + children: revealed ? 'Hide' : 'Reveal' + }) + ] + }) + ] + }); +}; +export default MaskedField; + +//# sourceMappingURL=MaskedField.js.map \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/components/MaskedField.js.map b/dist/globals/SiteIntegrations/components/MaskedField.js.map new file mode 100644 index 0000000..9879ae7 --- /dev/null +++ b/dist/globals/SiteIntegrations/components/MaskedField.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../../src/globals/SiteIntegrations/components/MaskedField.tsx"],"sourcesContent":["'use client'\nimport type { TextFieldClientComponent } from 'payload'\n\nimport { useField } from '@payloadcms/ui'\nimport { useState } from 'react'\n\nexport const MaskedField: TextFieldClientComponent = ({ field, path }) => {\n const { setValue, value } = useField({ path })\n const [revealed, setRevealed] = useState(false)\n const label = typeof field?.label === 'string' ? field.label : (field?.name ?? path)\n\n return (\n
\n \n
\n setValue(e.target.value)}\n style={{ flex: 1 }}\n type={revealed ? 'text' : 'password'}\n value={value ?? ''}\n />\n \n
\n
\n )\n}\nexport default MaskedField\n"],"names":["useField","useState","MaskedField","field","path","setValue","value","revealed","setRevealed","label","name","div","className","style","display","gap","input","autoComplete","onChange","e","target","flex","type","button","onClick","r"],"mappings":"AAAA;;AAGA,SAASA,QAAQ,QAAQ,iBAAgB;AACzC,SAASC,QAAQ,QAAQ,QAAO;AAEhC,OAAO,MAAMC,cAAwC,CAAC,EAAEC,KAAK,EAAEC,IAAI,EAAE;IACnE,MAAM,EAAEC,QAAQ,EAAEC,KAAK,EAAE,GAAGN,SAAiB;QAAEI;IAAK;IACpD,MAAM,CAACG,UAAUC,YAAY,GAAGP,SAAS;IACzC,MAAMQ,QAAQ,OAAON,OAAOM,UAAU,WAAWN,MAAMM,KAAK,GAAIN,OAAOO,QAAQN;IAE/E,qBACE,MAACO;QAAIC,WAAU;;0BACb,KAACH;gBAAMG,WAAU;0BAAeH;;0BAChC,MAACE;gBAAIE,OAAO;oBAAEC,SAAS;oBAAQC,KAAK;gBAAQ;;kCAC1C,KAACC;wBACCC,cAAa;wBACbC,UAAU,CAACC,IAAMd,SAASc,EAAEC,MAAM,CAACd,KAAK;wBACxCO,OAAO;4BAAEQ,MAAM;wBAAE;wBACjBC,MAAMf,WAAW,SAAS;wBAC1BD,OAAOA,SAAS;;kCAElB,KAACiB;wBAAOC,SAAS,IAAMhB,YAAY,CAACiB,IAAM,CAACA;wBAAIH,MAAK;kCACjDf,WAAW,SAAS;;;;;;AAK/B,EAAC;AACD,eAAeL,YAAW"} \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/fields/smtp.js b/dist/globals/SiteIntegrations/fields/smtp.js index 748dc0e..d05d5fb 100644 --- a/dist/globals/SiteIntegrations/fields/smtp.js +++ b/dist/globals/SiteIntegrations/fields/smtp.js @@ -37,7 +37,11 @@ name: 'smtpPassword', type: 'text', admin: { - description: 'SMTP account password.' + description: 'SMTP account password.', + // Masked in the UI (••••) — stored plaintext, readable for SMTP auth. + components: { + Field: '@intecion/ipal-kit/client#MaskedField' + } } }, { diff --git a/dist/globals/SiteIntegrations/fields/smtp.js.map b/dist/globals/SiteIntegrations/fields/smtp.js.map index 6ca3457..0ba2f09 100644 --- a/dist/globals/SiteIntegrations/fields/smtp.js.map +++ b/dist/globals/SiteIntegrations/fields/smtp.js.map @@ -1 +1 @@ -{"version":3,"sources":["../../../../src/globals/SiteIntegrations/fields/smtp.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * SMTP transport settings for outbound email.\n *\n * Protected at the global level (SiteIntegrations requires an authenticated\n * user), so all fields — including the password — stay editable in the admin\n * panel while remaining inaccessible to anonymous API requests.\n */\nexport const smtpFields: Field[] = [\n {\n type: 'row',\n fields: [\n {\n name: 'smtpHost',\n type: 'text',\n admin: { placeholder: 'smtp.example.com', width: '70%' },\n },\n {\n name: 'smtpPort',\n type: 'number',\n admin: { width: '30%' },\n defaultValue: 587,\n },\n ],\n },\n {\n name: 'smtpUser',\n type: 'text',\n admin: {\n description: 'SMTP account username.',\n },\n },\n {\n name: 'smtpPassword',\n type: 'text',\n admin: {\n description: 'SMTP account password.',\n },\n },\n {\n name: 'smtpFromAddress',\n type: 'email',\n admin: {\n description: 'Default \"from\" address for outgoing mail.',\n },\n },\n {\n name: 'smtpFromName',\n type: 'text',\n admin: {\n description: 'Default \"from\" display name.',\n },\n },\n]\n"],"names":["smtpFields","type","fields","name","admin","placeholder","width","defaultValue","description"],"mappings":"AAEA;;;;;;CAMC,GACD,OAAO,MAAMA,aAAsB;IACjC;QACEC,MAAM;QACNC,QAAQ;YACN;gBACEC,MAAM;gBACNF,MAAM;gBACNG,OAAO;oBAAEC,aAAa;oBAAoBC,OAAO;gBAAM;YACzD;YACA;gBACEH,MAAM;gBACNF,MAAM;gBACNG,OAAO;oBAAEE,OAAO;gBAAM;gBACtBC,cAAc;YAChB;SACD;IACH;IACA;QACEJ,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;IACA;QACEL,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;IACA;QACEL,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;IACA;QACEL,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;CACD,CAAA"} \ No newline at end of file +{"version":3,"sources":["../../../../src/globals/SiteIntegrations/fields/smtp.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * SMTP transport settings for outbound email.\n *\n * Protected at the global level (SiteIntegrations requires an authenticated\n * user), so all fields — including the password — stay editable in the admin\n * panel while remaining inaccessible to anonymous API requests.\n */\nexport const smtpFields: Field[] = [\n {\n type: 'row',\n fields: [\n {\n name: 'smtpHost',\n type: 'text',\n admin: { placeholder: 'smtp.example.com', width: '70%' },\n },\n {\n name: 'smtpPort',\n type: 'number',\n admin: { width: '30%' },\n defaultValue: 587,\n },\n ],\n },\n {\n name: 'smtpUser',\n type: 'text',\n admin: {\n description: 'SMTP account username.',\n },\n },\n {\n name: 'smtpPassword',\n type: 'text',\n admin: {\n description: 'SMTP account password.',\n // Masked in the UI (••••) — stored plaintext, readable for SMTP auth.\n components: {\n Field: '@intecion/ipal-kit/client#MaskedField',\n },\n },\n },\n {\n name: 'smtpFromAddress',\n type: 'email',\n admin: {\n description: 'Default \"from\" address for outgoing mail.',\n },\n },\n {\n name: 'smtpFromName',\n type: 'text',\n admin: {\n description: 'Default \"from\" display name.',\n },\n },\n]\n"],"names":["smtpFields","type","fields","name","admin","placeholder","width","defaultValue","description","components","Field"],"mappings":"AAEA;;;;;;CAMC,GACD,OAAO,MAAMA,aAAsB;IACjC;QACEC,MAAM;QACNC,QAAQ;YACN;gBACEC,MAAM;gBACNF,MAAM;gBACNG,OAAO;oBAAEC,aAAa;oBAAoBC,OAAO;gBAAM;YACzD;YACA;gBACEH,MAAM;gBACNF,MAAM;gBACNG,OAAO;oBAAEE,OAAO;gBAAM;gBACtBC,cAAc;YAChB;SACD;IACH;IACA;QACEJ,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;IACA;QACEL,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;YACb,sEAAsE;YACtEC,YAAY;gBACVC,OAAO;YACT;QACF;IACF;IACA;QACEP,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;IACA;QACEL,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;CACD,CAAA"} \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/fields/storage.js b/dist/globals/SiteIntegrations/fields/storage.js index f700fe0..99194cb 100644 --- a/dist/globals/SiteIntegrations/fields/storage.js +++ b/dist/globals/SiteIntegrations/fields/storage.js @@ -31,7 +31,11 @@ name: 'r2SecretAccessKey', type: 'text', admin: { - description: 'R2 secret access key.' + description: 'R2 secret access key.', + // Masked in the UI (••••) — stored plaintext, readable for R2 auth. + components: { + Field: '@intecion/ipal-kit/client#MaskedField' + } } } ]; diff --git a/dist/globals/SiteIntegrations/fields/storage.js.map b/dist/globals/SiteIntegrations/fields/storage.js.map index f84dc37..8f041d8 100644 --- a/dist/globals/SiteIntegrations/fields/storage.js.map +++ b/dist/globals/SiteIntegrations/fields/storage.js.map @@ -1 +1 @@ -{"version":3,"sources":["../../../../src/globals/SiteIntegrations/fields/storage.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * Cloudflare R2 storage credentials.\n * Reserved for future use — media offloading to R2.\n *\n * Protected at the global level (SiteIntegrations requires an authenticated\n * user), so the access keys stay editable in the admin panel while remaining\n * inaccessible to anonymous API requests.\n */\nexport const storageFields: Field[] = [\n {\n name: 'r2Bucket',\n type: 'text',\n admin: {\n description: 'R2 bucket name.',\n },\n },\n {\n name: 'r2Endpoint',\n type: 'text',\n admin: {\n description: 'R2 S3-compatible endpoint URL.',\n },\n },\n {\n name: 'r2AccessKeyId',\n type: 'text',\n admin: {\n description: 'R2 access key ID.',\n },\n },\n {\n name: 'r2SecretAccessKey',\n type: 'text',\n admin: {\n description: 'R2 secret access key.',\n },\n },\n]\n"],"names":["storageFields","name","type","admin","description"],"mappings":"AAEA;;;;;;;CAOC,GACD,OAAO,MAAMA,gBAAyB;IACpC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;CACD,CAAA"} \ No newline at end of file +{"version":3,"sources":["../../../../src/globals/SiteIntegrations/fields/storage.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * Cloudflare R2 storage credentials.\n * Reserved for future use — media offloading to R2.\n *\n * Protected at the global level (SiteIntegrations requires an authenticated\n * user), so the access keys stay editable in the admin panel while remaining\n * inaccessible to anonymous API requests.\n */\nexport const storageFields: Field[] = [\n {\n name: 'r2Bucket',\n type: 'text',\n admin: {\n description: 'R2 bucket name.',\n },\n },\n {\n name: 'r2Endpoint',\n type: 'text',\n admin: {\n description: 'R2 S3-compatible endpoint URL.',\n },\n },\n {\n name: 'r2AccessKeyId',\n type: 'text',\n admin: {\n description: 'R2 access key ID.',\n },\n },\n {\n name: 'r2SecretAccessKey',\n type: 'text',\n admin: {\n description: 'R2 secret access key.',\n // Masked in the UI (••••) — stored plaintext, readable for R2 auth.\n components: {\n Field: '@intecion/ipal-kit/client#MaskedField',\n },\n },\n },\n]\n"],"names":["storageFields","name","type","admin","description","components","Field"],"mappings":"AAEA;;;;;;;CAOC,GACD,OAAO,MAAMA,gBAAyB;IACpC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;YACb,oEAAoE;YACpEC,YAAY;gBACVC,OAAO;YACT;QACF;IACF;CACD,CAAA"} \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/fields/turnstile.js b/dist/globals/SiteIntegrations/fields/turnstile.js index be87901..80a43f9 100644 --- a/dist/globals/SiteIntegrations/fields/turnstile.js +++ b/dist/globals/SiteIntegrations/fields/turnstile.js @@ -17,7 +17,11 @@ name: 'turnstileSecretKey', type: 'text', admin: { - description: 'Secret key used for server-side verification.' + description: 'Secret key used for server-side verification.', + // Masked in the UI (••••) — stored plaintext, readable for verification. + components: { + Field: '@intecion/ipal-kit/client#MaskedField' + } } } ]; diff --git a/dist/globals/SiteIntegrations/fields/turnstile.js.map b/dist/globals/SiteIntegrations/fields/turnstile.js.map index 634b86a..e4a1288 100644 --- a/dist/globals/SiteIntegrations/fields/turnstile.js.map +++ b/dist/globals/SiteIntegrations/fields/turnstile.js.map @@ -1 +1 @@ -{"version":3,"sources":["../../../../src/globals/SiteIntegrations/fields/turnstile.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * Cloudflare Turnstile credentials.\n *\n * siteKey is public (rendered in the widget); secretKey is used for\n * server-side verification. Both are protected at the global level\n * (SiteIntegrations requires an authenticated user) rather than per-field,\n * so they remain editable in the admin panel.\n */\nexport const turnstileFields: Field[] = [\n {\n name: 'turnstileSiteKey',\n type: 'text',\n admin: {\n description: 'Public site key rendered in the Turnstile widget.',\n },\n },\n {\n name: 'turnstileSecretKey',\n type: 'text',\n admin: {\n description: 'Secret key used for server-side verification.',\n },\n },\n]\n"],"names":["turnstileFields","name","type","admin","description"],"mappings":"AAEA;;;;;;;CAOC,GACD,OAAO,MAAMA,kBAA2B;IACtC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;CACD,CAAA"} \ No newline at end of file +{"version":3,"sources":["../../../../src/globals/SiteIntegrations/fields/turnstile.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * Cloudflare Turnstile credentials.\n *\n * siteKey is public (rendered in the widget); secretKey is used for\n * server-side verification. Both are protected at the global level\n * (SiteIntegrations requires an authenticated user) rather than per-field,\n * so they remain editable in the admin panel.\n */\nexport const turnstileFields: Field[] = [\n {\n name: 'turnstileSiteKey',\n type: 'text',\n admin: {\n description: 'Public site key rendered in the Turnstile widget.',\n },\n },\n {\n name: 'turnstileSecretKey',\n type: 'text',\n admin: {\n description: 'Secret key used for server-side verification.',\n // Masked in the UI (••••) — stored plaintext, readable for verification.\n components: {\n Field: '@intecion/ipal-kit/client#MaskedField',\n },\n },\n },\n]\n"],"names":["turnstileFields","name","type","admin","description","components","Field"],"mappings":"AAEA;;;;;;;CAOC,GACD,OAAO,MAAMA,kBAA2B;IACtC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;YACb,yEAAyE;YACzEC,YAAY;gBACVC,OAAO;YACT;QACF;IACF;CACD,CAAA"} \ No newline at end of file diff --git a/dist/modules/forms/submitForm.js b/dist/modules/forms/submitForm.js index cf5217d..8449018 100644 --- a/dist/modules/forms/submitForm.js +++ b/dist/modules/forms/submitForm.js @@ -14,7 +14,7 @@ import { validateSubmission } from './validateSubmission.js'; * which goes out over panelSmtpAdapter. Storing the submission is enough. * * server-only: touches the Turnstile secret. - */ export async function submitForm({ data, formId, ip, maxPerMinute = 5, payload, turnstileToken }) { + */ export async function submitForm({ consentFieldName, data, formId, ip, maxPerMinute = 5, payload, turnstileToken }) { // 1. Rate limit — cheapest gate, drops a flood before any real work. if (maxPerMinute > 0 && ip) { if (!checkRateLimit({ @@ -43,7 +43,7 @@ import { validateSubmission } from './validateSubmission.js'; } // 3. Validate against the form's schema. A public endpoint can't trust the // shape of `data` — drop unknown keys, enforce required, cap length. - const validation = await validateSubmission(payload, formId, data); + const validation = await validateSubmission(payload, formId, data, consentFieldName); if (!validation.ok) { if (validation.reason === 'not_found') { return { @@ -51,6 +51,13 @@ import { validateSubmission } from './validateSubmission.js'; success: false }; } + if (validation.reason === 'consent') { + return { + field: validation.field, + reason: 'consent', + success: false + }; + } return { reason: 'validation', success: false, diff --git a/dist/modules/forms/submitForm.js.map b/dist/modules/forms/submitForm.js.map index b289675..597fd64 100644 --- a/dist/modules/forms/submitForm.js.map +++ b/dist/modules/forms/submitForm.js.map @@ -1 +1 @@ -{"version":3,"sources":["../../../src/modules/forms/submitForm.ts"],"sourcesContent":["import 'server-only'\n\nimport type { BasePayload } from 'payload'\n\nimport { verifyTurnstile } from '../turnstile/index.js'\nimport { checkRateLimit } from './rateLimit.js'\nimport { validateSubmission } from './validateSubmission.js'\n\nexport type SubmitFormArgs = {\n /** Submitted field data — shape matches the form's fields. */\n data: Record\n /** Form-builder form ID this submission belongs to. */\n formId: string\n /** Client IP — used for Turnstile and rate limiting. */\n ip?: string\n /**\n * Rate limit: max submissions per IP per minute. Defaults to 5.\n * Set to 0 to disable (e.g. when a real limiter sits in front).\n */\n maxPerMinute?: number\n payload: BasePayload\n /** Turnstile token; when present it is verified, when absent it is skipped. */\n turnstileToken?: string\n}\n\n/**\n * Why a submission failed — a code, never a user-facing string.\n *\n * The plugin knows what went wrong; it deliberately doesn't decide how to say\n * it. The frontend maps these to its own copy, in its own language, and renders\n * whatever component fits — a field error, a toast, a full message. The plugin\n * has no business choosing the wording or the locale.\n *\n * - `rate_limited` — too many submissions from this IP\n * - `turnstile` — bot check failed\n * - `validation` — a field is missing/too long, or unknown keys were sent;\n * `field` and `kind` narrow it down when a specific field is\n * at fault (absent for whole-payload problems like unknown keys)\n * - `not_found` — no form with this id\n * - `error` — persistence failed unexpectedly\n */\nexport type SubmitFailure =\n | {\n /** The offending field's name, when one field is at fault. */\n field?: string\n /** What was wrong with it. */\n kind?: 'required' | 'too_long' | 'unknown_fields'\n reason: 'validation'\n success: false\n }\n | { reason: 'error'; success: false }\n | { reason: 'not_found'; success: false }\n | { reason: 'rate_limited'; success: false }\n | { reason: 'turnstile'; success: false }\n\nexport type SubmitFormResult = { submissionId: number | string; success: true } | SubmitFailure\n\n/**\n * Handles a form submission end to end: rate limit, verify Turnstile, validate\n * against the form's own schema, then store.\n *\n * The order is cost-ascending on purpose — the cheapest checks reject first, so\n * a flood never reaches Turnstile's network call or the database.\n *\n * Emails aren't sent here. The form-builder sends whatever an editor configured\n * under the form's \"Emails\" tab (form-submissions hook → payload.sendEmail),\n * which goes out over panelSmtpAdapter. Storing the submission is enough.\n *\n * server-only: touches the Turnstile secret.\n */\nexport async function submitForm({\n data,\n formId,\n ip,\n maxPerMinute = 5,\n payload,\n turnstileToken,\n}: SubmitFormArgs): Promise {\n // 1. Rate limit — cheapest gate, drops a flood before any real work.\n if (maxPerMinute > 0 && ip) {\n if (!checkRateLimit({ key: ip, max: maxPerMinute })) {\n return { reason: 'rate_limited', success: false }\n }\n }\n\n // 2. Turnstile — verify when a token is supplied; reject on failure.\n if (turnstileToken !== undefined) {\n const ok = await verifyTurnstile({ ip, payload, token: turnstileToken })\n if (!ok) {\n return { reason: 'turnstile', success: false }\n }\n }\n\n // 3. Validate against the form's schema. A public endpoint can't trust the\n // shape of `data` — drop unknown keys, enforce required, cap length.\n const validation = await validateSubmission(payload, formId, data)\n if (!validation.ok) {\n if (validation.reason === 'not_found') {\n return { reason: 'not_found', success: false }\n }\n return {\n reason: 'validation',\n success: false,\n ...(validation.field ? { field: validation.field } : {}),\n ...(validation.kind ? { kind: validation.kind } : {}),\n }\n }\n\n // 4. Persist (form-builder shape: submissionData array). Triggers the email\n // hook. Only validated, known fields are stored.\n try {\n const submission = await payload.create({\n collection: 'form-submissions',\n data: {\n form: formId,\n submissionData: Object.entries(validation.cleaned).map(([field, value]) => ({\n field,\n value: value == null ? '' : String(value),\n })),\n } as never,\n })\n return { submissionId: submission.id, success: true }\n } catch (err) {\n payload.logger.error(`[ipal] Form submission failed: ${(err as Error).message}`)\n return { reason: 'error', success: false }\n }\n}\n"],"names":["verifyTurnstile","checkRateLimit","validateSubmission","submitForm","data","formId","ip","maxPerMinute","payload","turnstileToken","key","max","reason","success","undefined","ok","token","validation","field","kind","submission","create","collection","form","submissionData","Object","entries","cleaned","map","value","String","submissionId","id","err","logger","error","message"],"mappings":"AAAA,OAAO,cAAa;AAIpB,SAASA,eAAe,QAAQ,wBAAuB;AACvD,SAASC,cAAc,QAAQ,iBAAgB;AAC/C,SAASC,kBAAkB,QAAQ,0BAAyB;AAmD5D;;;;;;;;;;;;CAYC,GACD,OAAO,eAAeC,WAAW,EAC/BC,IAAI,EACJC,MAAM,EACNC,EAAE,EACFC,eAAe,CAAC,EAChBC,OAAO,EACPC,cAAc,EACC;IACf,qEAAqE;IACrE,IAAIF,eAAe,KAAKD,IAAI;QAC1B,IAAI,CAACL,eAAe;YAAES,KAAKJ;YAAIK,KAAKJ;QAAa,IAAI;YACnD,OAAO;gBAAEK,QAAQ;gBAAgBC,SAAS;YAAM;QAClD;IACF;IAEA,qEAAqE;IACrE,IAAIJ,mBAAmBK,WAAW;QAChC,MAAMC,KAAK,MAAMf,gBAAgB;YAAEM;YAAIE;YAASQ,OAAOP;QAAe;QACtE,IAAI,CAACM,IAAI;YACP,OAAO;gBAAEH,QAAQ;gBAAaC,SAAS;YAAM;QAC/C;IACF;IAEA,2EAA2E;IAC3E,wEAAwE;IACxE,MAAMI,aAAa,MAAMf,mBAAmBM,SAASH,QAAQD;IAC7D,IAAI,CAACa,WAAWF,EAAE,EAAE;QAClB,IAAIE,WAAWL,MAAM,KAAK,aAAa;YACrC,OAAO;gBAAEA,QAAQ;gBAAaC,SAAS;YAAM;QAC/C;QACA,OAAO;YACLD,QAAQ;YACRC,SAAS;YACT,GAAII,WAAWC,KAAK,GAAG;gBAAEA,OAAOD,WAAWC,KAAK;YAAC,IAAI,CAAC,CAAC;YACvD,GAAID,WAAWE,IAAI,GAAG;gBAAEA,MAAMF,WAAWE,IAAI;YAAC,IAAI,CAAC,CAAC;QACtD;IACF;IAEA,4EAA4E;IAC5E,oDAAoD;IACpD,IAAI;QACF,MAAMC,aAAa,MAAMZ,QAAQa,MAAM,CAAC;YACtCC,YAAY;YACZlB,MAAM;gBACJmB,MAAMlB;gBACNmB,gBAAgBC,OAAOC,OAAO,CAACT,WAAWU,OAAO,EAAEC,GAAG,CAAC,CAAC,CAACV,OAAOW,MAAM,GAAM,CAAA;wBAC1EX;wBACAW,OAAOA,SAAS,OAAO,KAAKC,OAAOD;oBACrC,CAAA;YACF;QACF;QACA,OAAO;YAAEE,cAAcX,WAAWY,EAAE;YAAEnB,SAAS;QAAK;IACtD,EAAE,OAAOoB,KAAK;QACZzB,QAAQ0B,MAAM,CAACC,KAAK,CAAC,CAAC,+BAA+B,EAAE,AAACF,IAAcG,OAAO,EAAE;QAC/E,OAAO;YAAExB,QAAQ;YAASC,SAAS;QAAM;IAC3C;AACF"} \ No newline at end of file +{"version":3,"sources":["../../../src/modules/forms/submitForm.ts"],"sourcesContent":["import 'server-only'\n\nimport type { BasePayload } from 'payload'\n\nimport { verifyTurnstile } from '../turnstile/index.js'\nimport { checkRateLimit } from './rateLimit.js'\nimport { validateSubmission } from './validateSubmission.js'\n\nexport type SubmitFormArgs = {\n /**\n * Name of the GDPR consent checkbox. A field with this name must be checked\n * for the submission to succeed (enforced server-side). Defaults to 'consent'.\n */\n consentFieldName?: string\n /** Submitted field data — shape matches the form's fields. */\n data: Record\n /** Form-builder form ID this submission belongs to. */\n formId: string\n /** Client IP — used for Turnstile and rate limiting. */\n ip?: string\n /**\n * Rate limit: max submissions per IP per minute. Defaults to 5.\n * Set to 0 to disable (e.g. when a real limiter sits in front).\n */\n maxPerMinute?: number\n payload: BasePayload\n /** Turnstile token; when present it is verified, when absent it is skipped. */\n turnstileToken?: string\n}\n\n/**\n * Why a submission failed — a code, never a user-facing string.\n *\n * The plugin knows what went wrong; it deliberately doesn't decide how to say\n * it. The frontend maps these to its own copy, in its own language, and renders\n * whatever component fits — a field error, a toast, a full message. The plugin\n * has no business choosing the wording or the locale.\n *\n * - `rate_limited` — too many submissions from this IP\n * - `turnstile` — bot check failed\n * - `validation` — a field is missing/too long, or unknown keys were sent;\n * `field` and `kind` narrow it down when a specific field is\n * at fault (absent for whole-payload problems like unknown keys)\n * - `not_found` — no form with this id\n * - `consent` — a required GDPR consent checkbox was left unchecked\n * - `error` — persistence failed unexpectedly\n */\nexport type SubmitFailure =\n | {\n /** The offending field's name, when one field is at fault. */\n field?: string\n /** What was wrong with it. */\n kind?: 'required' | 'too_long' | 'unknown_fields'\n reason: 'validation'\n success: false\n }\n | {\n field?: string\n /** A GDPR consent field existed on the form but wasn't checked. */\n reason: 'consent'\n success: false\n }\n | { reason: 'error'; success: false }\n | { reason: 'not_found'; success: false }\n | { reason: 'rate_limited'; success: false }\n | { reason: 'turnstile'; success: false }\n\nexport type SubmitFormResult = { submissionId: number | string; success: true } | SubmitFailure\n\n/**\n * Handles a form submission end to end: rate limit, verify Turnstile, validate\n * against the form's own schema, then store.\n *\n * The order is cost-ascending on purpose — the cheapest checks reject first, so\n * a flood never reaches Turnstile's network call or the database.\n *\n * Emails aren't sent here. The form-builder sends whatever an editor configured\n * under the form's \"Emails\" tab (form-submissions hook → payload.sendEmail),\n * which goes out over panelSmtpAdapter. Storing the submission is enough.\n *\n * server-only: touches the Turnstile secret.\n */\nexport async function submitForm({\n consentFieldName,\n data,\n formId,\n ip,\n maxPerMinute = 5,\n payload,\n turnstileToken,\n}: SubmitFormArgs): Promise {\n // 1. Rate limit — cheapest gate, drops a flood before any real work.\n if (maxPerMinute > 0 && ip) {\n if (!checkRateLimit({ key: ip, max: maxPerMinute })) {\n return { reason: 'rate_limited', success: false }\n }\n }\n\n // 2. Turnstile — verify when a token is supplied; reject on failure.\n if (turnstileToken !== undefined) {\n const ok = await verifyTurnstile({ ip, payload, token: turnstileToken })\n if (!ok) {\n return { reason: 'turnstile', success: false }\n }\n }\n\n // 3. Validate against the form's schema. A public endpoint can't trust the\n // shape of `data` — drop unknown keys, enforce required, cap length.\n const validation = await validateSubmission(payload, formId, data, consentFieldName)\n if (!validation.ok) {\n if (validation.reason === 'not_found') {\n return { reason: 'not_found', success: false }\n }\n if (validation.reason === 'consent') {\n return { field: validation.field, reason: 'consent', success: false }\n }\n return {\n reason: 'validation',\n success: false,\n ...(validation.field ? { field: validation.field } : {}),\n ...(validation.kind ? { kind: validation.kind } : {}),\n }\n }\n\n // 4. Persist (form-builder shape: submissionData array). Triggers the email\n // hook. Only validated, known fields are stored.\n try {\n const submission = await payload.create({\n collection: 'form-submissions',\n data: {\n form: formId,\n submissionData: Object.entries(validation.cleaned).map(([field, value]) => ({\n field,\n value: value == null ? '' : String(value),\n })),\n } as never,\n })\n return { submissionId: submission.id, success: true }\n } catch (err) {\n payload.logger.error(`[ipal] Form submission failed: ${(err as Error).message}`)\n return { reason: 'error', success: false }\n }\n}\n"],"names":["verifyTurnstile","checkRateLimit","validateSubmission","submitForm","consentFieldName","data","formId","ip","maxPerMinute","payload","turnstileToken","key","max","reason","success","undefined","ok","token","validation","field","kind","submission","create","collection","form","submissionData","Object","entries","cleaned","map","value","String","submissionId","id","err","logger","error","message"],"mappings":"AAAA,OAAO,cAAa;AAIpB,SAASA,eAAe,QAAQ,wBAAuB;AACvD,SAASC,cAAc,QAAQ,iBAAgB;AAC/C,SAASC,kBAAkB,QAAQ,0BAAyB;AA+D5D;;;;;;;;;;;;CAYC,GACD,OAAO,eAAeC,WAAW,EAC/BC,gBAAgB,EAChBC,IAAI,EACJC,MAAM,EACNC,EAAE,EACFC,eAAe,CAAC,EAChBC,OAAO,EACPC,cAAc,EACC;IACf,qEAAqE;IACrE,IAAIF,eAAe,KAAKD,IAAI;QAC1B,IAAI,CAACN,eAAe;YAAEU,KAAKJ;YAAIK,KAAKJ;QAAa,IAAI;YACnD,OAAO;gBAAEK,QAAQ;gBAAgBC,SAAS;YAAM;QAClD;IACF;IAEA,qEAAqE;IACrE,IAAIJ,mBAAmBK,WAAW;QAChC,MAAMC,KAAK,MAAMhB,gBAAgB;YAAEO;YAAIE;YAASQ,OAAOP;QAAe;QACtE,IAAI,CAACM,IAAI;YACP,OAAO;gBAAEH,QAAQ;gBAAaC,SAAS;YAAM;QAC/C;IACF;IAEA,2EAA2E;IAC3E,wEAAwE;IACxE,MAAMI,aAAa,MAAMhB,mBAAmBO,SAASH,QAAQD,MAAMD;IACnE,IAAI,CAACc,WAAWF,EAAE,EAAE;QAClB,IAAIE,WAAWL,MAAM,KAAK,aAAa;YACrC,OAAO;gBAAEA,QAAQ;gBAAaC,SAAS;YAAM;QAC/C;QACA,IAAII,WAAWL,MAAM,KAAK,WAAW;YACnC,OAAO;gBAAEM,OAAOD,WAAWC,KAAK;gBAAEN,QAAQ;gBAAWC,SAAS;YAAM;QACtE;QACA,OAAO;YACLD,QAAQ;YACRC,SAAS;YACT,GAAII,WAAWC,KAAK,GAAG;gBAAEA,OAAOD,WAAWC,KAAK;YAAC,IAAI,CAAC,CAAC;YACvD,GAAID,WAAWE,IAAI,GAAG;gBAAEA,MAAMF,WAAWE,IAAI;YAAC,IAAI,CAAC,CAAC;QACtD;IACF;IAEA,4EAA4E;IAC5E,oDAAoD;IACpD,IAAI;QACF,MAAMC,aAAa,MAAMZ,QAAQa,MAAM,CAAC;YACtCC,YAAY;YACZlB,MAAM;gBACJmB,MAAMlB;gBACNmB,gBAAgBC,OAAOC,OAAO,CAACT,WAAWU,OAAO,EAAEC,GAAG,CAAC,CAAC,CAACV,OAAOW,MAAM,GAAM,CAAA;wBAC1EX;wBACAW,OAAOA,SAAS,OAAO,KAAKC,OAAOD;oBACrC,CAAA;YACF;QACF;QACA,OAAO;YAAEE,cAAcX,WAAWY,EAAE;YAAEnB,SAAS;QAAK;IACtD,EAAE,OAAOoB,KAAK;QACZzB,QAAQ0B,MAAM,CAACC,KAAK,CAAC,CAAC,+BAA+B,EAAE,AAACF,IAAcG,OAAO,EAAE;QAC/E,OAAO;YAAExB,QAAQ;YAASC,SAAS;QAAM;IAC3C;AACF"} \ No newline at end of file diff --git a/dist/modules/forms/types.js.map b/dist/modules/forms/types.js.map index c58be3d..edbc9bd 100644 --- a/dist/modules/forms/types.js.map +++ b/dist/modules/forms/types.js.map @@ -1 +1 @@ -{"version":3,"sources":["../../../src/modules/forms/types.ts"],"sourcesContent":["import type { CollectionConfig, Field } from 'payload'\n\n/**\n * Receives the collection's default fields and returns the final list — add,\n * remove, or reorder. Same shape the form-builder uses.\n */\nexport type FormsFieldsOverride = (args: { defaultFields: Field[] }) => Field[]\n\n/**\n * Overrides for a forms-related collection: replace the fields and/or any\n * other collection setting (admin, access, hooks…).\n */\nexport type FormsCollectionOverrides = {\n fields?: FormsFieldsOverride\n} & Partial>\n\n/**\n * Forms configuration — mirrors the fields a client enables in the\n * form-builder plugin. Kept minimal; the plugin passes these through.\n */\nexport type FormsOption = {\n /** Field types available in the form builder. Sensible defaults applied. */\n fields?: {\n checkbox?: boolean\n email?: boolean\n message?: boolean\n number?: boolean\n payment?: boolean\n select?: boolean\n text?: boolean\n textarea?: boolean\n }\n /**\n * Override the forms collection. The plugin stays opinion-free about what a\n * form needs beyond its fields — a client that wants, say, a per-form\n * notification address adds it here:\n *\n * formOverrides: {\n * fields: ({ defaultFields }) => [\n * ...defaultFields,\n * { name: 'notificationEmail', type: 'email' },\n * ],\n * }\n */\n formOverrides?: FormsCollectionOverrides\n /** Override the form-submissions collection (same shape). */\n formSubmissionOverrides?: FormsCollectionOverrides\n /** Collections a form can redirect to (e.g. ['pages']). */\n redirectRelationships?: string[]\n}\n"],"names":[],"mappings":"AAgBA;;;CAGC,GACD,WA6BC"} \ No newline at end of file +{"version":3,"sources":["../../../src/modules/forms/types.ts"],"sourcesContent":["import type { CollectionConfig, Field } from 'payload'\n\n/**\n * Receives the collection's default fields and returns the final list — add,\n * remove, or reorder. Same shape the form-builder uses.\n */\nexport type FormsFieldsOverride = (args: { defaultFields: Field[] }) => Field[]\n\n/**\n * Overrides for a forms-related collection: replace the fields and/or any\n * other collection setting (admin, access, hooks…).\n */\nexport type FormsCollectionOverrides = {\n fields?: FormsFieldsOverride\n} & Partial>\n\n/**\n * Forms configuration — mirrors the fields a client enables in the\n * form-builder plugin. Kept minimal; the plugin passes these through.\n */\nexport type FormsOption = {\n /**\n * Name of the checkbox field treated as a GDPR consent gate. A form field\n * with this name must be checked for submission to succeed — enforced\n * server-side in submitForm. Defaults to 'consent'.\n */\n consentFieldName?: string\n /** Field types available in the form builder. Sensible defaults applied. */\n fields?: {\n checkbox?: boolean\n email?: boolean\n message?: boolean\n number?: boolean\n payment?: boolean\n select?: boolean\n text?: boolean\n textarea?: boolean\n }\n /**\n * Override the forms collection. The plugin stays opinion-free about what a\n * form needs beyond its fields — a client that wants, say, a per-form\n * notification address adds it here:\n *\n * formOverrides: {\n * fields: ({ defaultFields }) => [\n * ...defaultFields,\n * { name: 'notificationEmail', type: 'email' },\n * ],\n * }\n */\n formOverrides?: FormsCollectionOverrides\n /** Override the form-submissions collection (same shape). */\n formSubmissionOverrides?: FormsCollectionOverrides\n /** Collections a form can redirect to (e.g. ['pages']). */\n redirectRelationships?: string[]\n}\n"],"names":[],"mappings":"AAgBA;;;CAGC,GACD,WAmCC"} \ No newline at end of file diff --git a/dist/modules/forms/validateSubmission.js b/dist/modules/forms/validateSubmission.js index 3095f74..3527b94 100644 --- a/dist/modules/forms/validateSubmission.js +++ b/dist/modules/forms/validateSubmission.js @@ -12,6 +12,14 @@ 'g-recaptcha-response' ]); /** Hard ceiling on a single field's length, independent of the form config. */ const MAX_FIELD_LENGTH = 5000; +/** + * Default name for a GDPR consent field. A checkbox with this name is treated + * as a consent gate: it MUST be checked for the submission to go through, + * enforced here server-side regardless of how the field was configured in the + * panel (so an editor can't weaken it by forgetting `required` or, worse, + * pre-ticking it with defaultValue: true — which GDPR forbids). Configurable + * via FormsOption.consentFieldName. + */ const DEFAULT_CONSENT_FIELD = 'consent'; /** * Checks submitted data against the form's own definition, rather than trusting * whatever arrived. @@ -24,7 +32,7 @@ * * Returns the loaded form on success so the caller doesn't fetch it twice, and * a code + offending field on failure so the frontend can point at it. - */ export async function validateSubmission(payload, formId, data) { + */ export async function validateSubmission(payload, formId, data, consentFieldName = DEFAULT_CONSENT_FIELD) { let form; try { form = await payload.findByID({ @@ -47,7 +55,19 @@ for (const field of fields){ const value = data[field.name]; const isBlank = value == null || typeof value === 'string' && value.trim() === '' || value === false; - if (field.required && isBlank) { + // GDPR consent gate: a field matching the consent name must be truthy + // (checked). Enforced independently of `required`, so it can't be weakened + // in the panel. This is the one field where server-side enforcement is the + // legal guarantee — the frontend can't bypass it, the editor can't misset it. + if (field.name === consentFieldName) { + if (value !== true) { + return { + field: field.name, + ok: false, + reason: 'consent' + }; + } + } else if (field.required && isBlank) { return { field: field.name, kind: 'required', diff --git a/dist/modules/forms/validateSubmission.js.map b/dist/modules/forms/validateSubmission.js.map index 22b5b14..8b8c6b9 100644 --- a/dist/modules/forms/validateSubmission.js.map +++ b/dist/modules/forms/validateSubmission.js.map @@ -1 +1 @@ -{"version":3,"sources":["../../../src/modules/forms/validateSubmission.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\n/** A form-builder field, trimmed to what validation needs. */\ntype FormField = {\n blockType?: string\n label?: string\n name?: string\n required?: boolean | null\n}\n\ntype FormDoc = {\n fields?: FormField[]\n id: number | string\n /** Per-form notification address, when the client added the field. */\n notificationEmail?: string\n title?: string\n}\n\n/**\n * Validation outcome — codes, not user-facing strings. The frontend turns these\n * into its own copy (see SubmitFailure in submitForm).\n */\nexport type FormValidationResult =\n | {\n /** Offending field, when a single field is at fault. */\n field?: string\n kind: 'required' | 'too_long' | 'unknown_fields'\n ok: false\n reason: 'invalid'\n }\n | { cleaned: Record; form: FormDoc; ok: true }\n | { ok: false; reason: 'not_found' }\n\n/** Field block types that don't carry a submittable value. */\nconst NON_DATA_BLOCKS = new Set(['message'])\n\n/**\n * Keys injected by the captcha widget itself, not by the form definition.\n * Cloudflare Turnstile adds a hidden after\n * a successful challenge; reCAPTCHA adds 'g-recaptcha-response'. Since the\n * plugin drives Turnstile end-to-end, these are legitimate artifacts — they\n * must not count as \"unknown fields\" and trip the anti-tampering check.\n */\nconst CAPTCHA_KEYS = new Set(['cf-turnstile-response', 'g-recaptcha-response'])\n\n/** Hard ceiling on a single field's length, independent of the form config. */\nconst MAX_FIELD_LENGTH = 5000\n\n/**\n * Checks submitted data against the form's own definition, rather than trusting\n * whatever arrived.\n *\n * The server action is a public endpoint: a caller can skip the rendered form\n * and post arbitrary keys. Without this, unknown fields would be stored,\n * required fields could be missing, and an oversized value could sail through.\n * So we load the form, keep only keys that are real fields, reject when a\n * required one is blank, and cap length.\n *\n * Returns the loaded form on success so the caller doesn't fetch it twice, and\n * a code + offending field on failure so the frontend can point at it.\n */\nexport async function validateSubmission(\n payload: BasePayload,\n formId: string,\n data: Record,\n): Promise {\n let form: FormDoc\n try {\n form = (await payload.findByID({\n id: formId,\n collection: 'forms',\n depth: 0,\n })) as FormDoc\n } catch {\n return { ok: false, reason: 'not_found' }\n }\n\n const fields = (form.fields ?? []).filter(\n (f): f is { name: string } & FormField =>\n typeof f.name === 'string' && !NON_DATA_BLOCKS.has(f.blockType ?? ''),\n )\n const known = new Map(fields.map((f) => [f.name, f]))\n\n const cleaned: Record = {}\n\n for (const field of fields) {\n const value = data[field.name]\n const isBlank =\n value == null || (typeof value === 'string' && value.trim() === '') || value === false\n\n if (field.required && isBlank) {\n return { field: field.name, kind: 'required', ok: false, reason: 'invalid' }\n }\n\n if (typeof value === 'string' && value.length > MAX_FIELD_LENGTH) {\n return { field: field.name, kind: 'too_long', ok: false, reason: 'invalid' }\n }\n\n // Only carry through keys that belong to the form — unknown keys from a\n // hand-crafted request are dropped, not stored.\n if (value !== undefined) {\n cleaned[field.name] = value\n }\n }\n\n // Reject outright if the payload carried keys the form doesn't define — a\n // sign the request wasn't produced by the rendered form. Captcha keys are\n // exempt: the widget injects them into the rendered form, so they're expected,\n // not tampering.\n const unknownKeys = Object.keys(data).filter((k) => !known.has(k) && !CAPTCHA_KEYS.has(k))\n if (unknownKeys.length > 0) {\n return { kind: 'unknown_fields', ok: false, reason: 'invalid' }\n }\n\n return { cleaned, form, ok: true }\n}\n"],"names":["NON_DATA_BLOCKS","Set","CAPTCHA_KEYS","MAX_FIELD_LENGTH","validateSubmission","payload","formId","data","form","findByID","id","collection","depth","ok","reason","fields","filter","f","name","has","blockType","known","Map","map","cleaned","field","value","isBlank","trim","required","kind","length","undefined","unknownKeys","Object","keys","k"],"mappings":"AAiCA,4DAA4D,GAC5D,MAAMA,kBAAkB,IAAIC,IAAI;IAAC;CAAU;AAE3C;;;;;;CAMC,GACD,MAAMC,eAAe,IAAID,IAAI;IAAC;IAAyB;CAAuB;AAE9E,6EAA6E,GAC7E,MAAME,mBAAmB;AAEzB;;;;;;;;;;;;CAYC,GACD,OAAO,eAAeC,mBACpBC,OAAoB,EACpBC,MAAc,EACdC,IAA6B;IAE7B,IAAIC;IACJ,IAAI;QACFA,OAAQ,MAAMH,QAAQI,QAAQ,CAAC;YAC7BC,IAAIJ;YACJK,YAAY;YACZC,OAAO;QACT;IACF,EAAE,OAAM;QACN,OAAO;YAAEC,IAAI;YAAOC,QAAQ;QAAY;IAC1C;IAEA,MAAMC,SAAS,AAACP,CAAAA,KAAKO,MAAM,IAAI,EAAE,AAAD,EAAGC,MAAM,CACvC,CAACC,IACC,OAAOA,EAAEC,IAAI,KAAK,YAAY,CAAClB,gBAAgBmB,GAAG,CAACF,EAAEG,SAAS,IAAI;IAEtE,MAAMC,QAAQ,IAAIC,IAAIP,OAAOQ,GAAG,CAAC,CAACN,IAAM;YAACA,EAAEC,IAAI;YAAED;SAAE;IAEnD,MAAMO,UAAmC,CAAC;IAE1C,KAAK,MAAMC,SAASV,OAAQ;QAC1B,MAAMW,QAAQnB,IAAI,CAACkB,MAAMP,IAAI,CAAC;QAC9B,MAAMS,UACJD,SAAS,QAAS,OAAOA,UAAU,YAAYA,MAAME,IAAI,OAAO,MAAOF,UAAU;QAEnF,IAAID,MAAMI,QAAQ,IAAIF,SAAS;YAC7B,OAAO;gBAAEF,OAAOA,MAAMP,IAAI;gBAAEY,MAAM;gBAAYjB,IAAI;gBAAOC,QAAQ;YAAU;QAC7E;QAEA,IAAI,OAAOY,UAAU,YAAYA,MAAMK,MAAM,GAAG5B,kBAAkB;YAChE,OAAO;gBAAEsB,OAAOA,MAAMP,IAAI;gBAAEY,MAAM;gBAAYjB,IAAI;gBAAOC,QAAQ;YAAU;QAC7E;QAEA,wEAAwE;QACxE,gDAAgD;QAChD,IAAIY,UAAUM,WAAW;YACvBR,OAAO,CAACC,MAAMP,IAAI,CAAC,GAAGQ;QACxB;IACF;IAEA,0EAA0E;IAC1E,0EAA0E;IAC1E,+EAA+E;IAC/E,iBAAiB;IACjB,MAAMO,cAAcC,OAAOC,IAAI,CAAC5B,MAAMS,MAAM,CAAC,CAACoB,IAAM,CAACf,MAAMF,GAAG,CAACiB,MAAM,CAAClC,aAAaiB,GAAG,CAACiB;IACvF,IAAIH,YAAYF,MAAM,GAAG,GAAG;QAC1B,OAAO;YAAED,MAAM;YAAkBjB,IAAI;YAAOC,QAAQ;QAAU;IAChE;IAEA,OAAO;QAAEU;QAAShB;QAAMK,IAAI;IAAK;AACnC"} \ No newline at end of file +{"version":3,"sources":["../../../src/modules/forms/validateSubmission.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\n/** A form-builder field, trimmed to what validation needs. */\ntype FormField = {\n blockType?: string\n label?: string\n name?: string\n required?: boolean | null\n}\n\ntype FormDoc = {\n fields?: FormField[]\n id: number | string\n /** Per-form notification address, when the client added the field. */\n notificationEmail?: string\n title?: string\n}\n\n/**\n * Validation outcome — codes, not user-facing strings. The frontend turns these\n * into its own copy (see SubmitFailure in submitForm).\n */\nexport type FormValidationResult =\n | {\n /** Offending field, when a single field is at fault. */\n field?: string\n kind: 'required' | 'too_long' | 'unknown_fields'\n ok: false\n reason: 'invalid'\n }\n | { cleaned: Record; form: FormDoc; ok: true }\n | { field: string; ok: false; reason: 'consent' }\n | { ok: false; reason: 'not_found' }\n\n/** Field block types that don't carry a submittable value. */\nconst NON_DATA_BLOCKS = new Set(['message'])\n\n/**\n * Keys injected by the captcha widget itself, not by the form definition.\n * Cloudflare Turnstile adds a hidden after\n * a successful challenge; reCAPTCHA adds 'g-recaptcha-response'. Since the\n * plugin drives Turnstile end-to-end, these are legitimate artifacts — they\n * must not count as \"unknown fields\" and trip the anti-tampering check.\n */\nconst CAPTCHA_KEYS = new Set(['cf-turnstile-response', 'g-recaptcha-response'])\n\n/** Hard ceiling on a single field's length, independent of the form config. */\nconst MAX_FIELD_LENGTH = 5000\n\n/**\n * Default name for a GDPR consent field. A checkbox with this name is treated\n * as a consent gate: it MUST be checked for the submission to go through,\n * enforced here server-side regardless of how the field was configured in the\n * panel (so an editor can't weaken it by forgetting `required` or, worse,\n * pre-ticking it with defaultValue: true — which GDPR forbids). Configurable\n * via FormsOption.consentFieldName.\n */\nconst DEFAULT_CONSENT_FIELD = 'consent'\n\n/**\n * Checks submitted data against the form's own definition, rather than trusting\n * whatever arrived.\n *\n * The server action is a public endpoint: a caller can skip the rendered form\n * and post arbitrary keys. Without this, unknown fields would be stored,\n * required fields could be missing, and an oversized value could sail through.\n * So we load the form, keep only keys that are real fields, reject when a\n * required one is blank, and cap length.\n *\n * Returns the loaded form on success so the caller doesn't fetch it twice, and\n * a code + offending field on failure so the frontend can point at it.\n */\nexport async function validateSubmission(\n payload: BasePayload,\n formId: string,\n data: Record,\n consentFieldName: string = DEFAULT_CONSENT_FIELD,\n): Promise {\n let form: FormDoc\n try {\n form = (await payload.findByID({\n id: formId,\n collection: 'forms',\n depth: 0,\n })) as FormDoc\n } catch {\n return { ok: false, reason: 'not_found' }\n }\n\n const fields = (form.fields ?? []).filter(\n (f): f is { name: string } & FormField =>\n typeof f.name === 'string' && !NON_DATA_BLOCKS.has(f.blockType ?? ''),\n )\n const known = new Map(fields.map((f) => [f.name, f]))\n\n const cleaned: Record = {}\n\n for (const field of fields) {\n const value = data[field.name]\n const isBlank =\n value == null || (typeof value === 'string' && value.trim() === '') || value === false\n\n // GDPR consent gate: a field matching the consent name must be truthy\n // (checked). Enforced independently of `required`, so it can't be weakened\n // in the panel. This is the one field where server-side enforcement is the\n // legal guarantee — the frontend can't bypass it, the editor can't misset it.\n if (field.name === consentFieldName) {\n if (value !== true) {\n return { field: field.name, ok: false, reason: 'consent' }\n }\n } else if (field.required && isBlank) {\n return { field: field.name, kind: 'required', ok: false, reason: 'invalid' }\n }\n\n if (typeof value === 'string' && value.length > MAX_FIELD_LENGTH) {\n return { field: field.name, kind: 'too_long', ok: false, reason: 'invalid' }\n }\n\n // Only carry through keys that belong to the form — unknown keys from a\n // hand-crafted request are dropped, not stored.\n if (value !== undefined) {\n cleaned[field.name] = value\n }\n }\n\n // Reject outright if the payload carried keys the form doesn't define — a\n // sign the request wasn't produced by the rendered form. Captcha keys are\n // exempt: the widget injects them into the rendered form, so they're expected,\n // not tampering.\n const unknownKeys = Object.keys(data).filter((k) => !known.has(k) && !CAPTCHA_KEYS.has(k))\n if (unknownKeys.length > 0) {\n return { kind: 'unknown_fields', ok: false, reason: 'invalid' }\n }\n\n return { cleaned, form, ok: true }\n}\n"],"names":["NON_DATA_BLOCKS","Set","CAPTCHA_KEYS","MAX_FIELD_LENGTH","DEFAULT_CONSENT_FIELD","validateSubmission","payload","formId","data","consentFieldName","form","findByID","id","collection","depth","ok","reason","fields","filter","f","name","has","blockType","known","Map","map","cleaned","field","value","isBlank","trim","required","kind","length","undefined","unknownKeys","Object","keys","k"],"mappings":"AAkCA,4DAA4D,GAC5D,MAAMA,kBAAkB,IAAIC,IAAI;IAAC;CAAU;AAE3C;;;;;;CAMC,GACD,MAAMC,eAAe,IAAID,IAAI;IAAC;IAAyB;CAAuB;AAE9E,6EAA6E,GAC7E,MAAME,mBAAmB;AAEzB;;;;;;;CAOC,GACD,MAAMC,wBAAwB;AAE9B;;;;;;;;;;;;CAYC,GACD,OAAO,eAAeC,mBACpBC,OAAoB,EACpBC,MAAc,EACdC,IAA6B,EAC7BC,mBAA2BL,qBAAqB;IAEhD,IAAIM;IACJ,IAAI;QACFA,OAAQ,MAAMJ,QAAQK,QAAQ,CAAC;YAC7BC,IAAIL;YACJM,YAAY;YACZC,OAAO;QACT;IACF,EAAE,OAAM;QACN,OAAO;YAAEC,IAAI;YAAOC,QAAQ;QAAY;IAC1C;IAEA,MAAMC,SAAS,AAACP,CAAAA,KAAKO,MAAM,IAAI,EAAE,AAAD,EAAGC,MAAM,CACvC,CAACC,IACC,OAAOA,EAAEC,IAAI,KAAK,YAAY,CAACpB,gBAAgBqB,GAAG,CAACF,EAAEG,SAAS,IAAI;IAEtE,MAAMC,QAAQ,IAAIC,IAAIP,OAAOQ,GAAG,CAAC,CAACN,IAAM;YAACA,EAAEC,IAAI;YAAED;SAAE;IAEnD,MAAMO,UAAmC,CAAC;IAE1C,KAAK,MAAMC,SAASV,OAAQ;QAC1B,MAAMW,QAAQpB,IAAI,CAACmB,MAAMP,IAAI,CAAC;QAC9B,MAAMS,UACJD,SAAS,QAAS,OAAOA,UAAU,YAAYA,MAAME,IAAI,OAAO,MAAOF,UAAU;QAEnF,sEAAsE;QACtE,2EAA2E;QAC3E,2EAA2E;QAC3E,8EAA8E;QAC9E,IAAID,MAAMP,IAAI,KAAKX,kBAAkB;YACnC,IAAImB,UAAU,MAAM;gBAClB,OAAO;oBAAED,OAAOA,MAAMP,IAAI;oBAAEL,IAAI;oBAAOC,QAAQ;gBAAU;YAC3D;QACF,OAAO,IAAIW,MAAMI,QAAQ,IAAIF,SAAS;YACpC,OAAO;gBAAEF,OAAOA,MAAMP,IAAI;gBAAEY,MAAM;gBAAYjB,IAAI;gBAAOC,QAAQ;YAAU;QAC7E;QAEA,IAAI,OAAOY,UAAU,YAAYA,MAAMK,MAAM,GAAG9B,kBAAkB;YAChE,OAAO;gBAAEwB,OAAOA,MAAMP,IAAI;gBAAEY,MAAM;gBAAYjB,IAAI;gBAAOC,QAAQ;YAAU;QAC7E;QAEA,wEAAwE;QACxE,gDAAgD;QAChD,IAAIY,UAAUM,WAAW;YACvBR,OAAO,CAACC,MAAMP,IAAI,CAAC,GAAGQ;QACxB;IACF;IAEA,0EAA0E;IAC1E,0EAA0E;IAC1E,+EAA+E;IAC/E,iBAAiB;IACjB,MAAMO,cAAcC,OAAOC,IAAI,CAAC7B,MAAMU,MAAM,CAAC,CAACoB,IAAM,CAACf,MAAMF,GAAG,CAACiB,MAAM,CAACpC,aAAamB,GAAG,CAACiB;IACvF,IAAIH,YAAYF,MAAM,GAAG,GAAG;QAC1B,OAAO;YAAED,MAAM;YAAkBjB,IAAI;YAAOC,QAAQ;QAAU;IAChE;IAEA,OAAO;QAAEU;QAAShB;QAAMK,IAAI;IAAK;AACnC"} \ No newline at end of file diff --git a/dist/modules/notifications/defaults.js b/dist/modules/notifications/defaults.js new file mode 100644 index 0000000..2b5550b --- /dev/null +++ b/dist/modules/notifications/defaults.js @@ -0,0 +1,17 @@ +/** + * Built-in English fallbacks, used per field when the Notifications global + * leaves a text empty. Same philosophy as consent FALLBACK: the site works out + * of the box, editors override per locale as needed. + */ export const NOTIFICATION_FALLBACK = { + form: { + success: 'Thank you — your message has been sent.', + error: 'Something went wrong. Please try again later.', + rateLimited: 'Too many attempts. Please wait a moment and try again.', + turnstile: 'Captcha verification failed. Please try again.', + validation: 'Please check the {field} field and try again.', + consent: 'Please accept the privacy policy to continue.', + notFound: 'This form is no longer available.' + } +}; + +//# sourceMappingURL=defaults.js.map \ No newline at end of file diff --git a/dist/modules/notifications/defaults.js.map b/dist/modules/notifications/defaults.js.map new file mode 100644 index 0000000..58285b4 --- /dev/null +++ b/dist/modules/notifications/defaults.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/notifications/defaults.ts"],"sourcesContent":["import type { NotificationTexts } from './types.js'\n\n/**\n * Built-in English fallbacks, used per field when the Notifications global\n * leaves a text empty. Same philosophy as consent FALLBACK: the site works out\n * of the box, editors override per locale as needed.\n */\nexport const NOTIFICATION_FALLBACK: NotificationTexts = {\n form: {\n success: 'Thank you — your message has been sent.',\n error: 'Something went wrong. Please try again later.',\n rateLimited: 'Too many attempts. Please wait a moment and try again.',\n turnstile: 'Captcha verification failed. Please try again.',\n validation: 'Please check the {field} field and try again.',\n consent: 'Please accept the privacy policy to continue.',\n notFound: 'This form is no longer available.',\n },\n}\n"],"names":["NOTIFICATION_FALLBACK","form","success","error","rateLimited","turnstile","validation","consent","notFound"],"mappings":"AAEA;;;;CAIC,GACD,OAAO,MAAMA,wBAA2C;IACtDC,MAAM;QACJC,SAAS;QACTC,OAAO;QACPC,aAAa;QACbC,WAAW;QACXC,YAAY;QACZC,SAAS;QACTC,UAAU;IACZ;AACF,EAAC"} \ No newline at end of file diff --git a/dist/modules/notifications/getNotificationTexts.js b/dist/modules/notifications/getNotificationTexts.js new file mode 100644 index 0000000..c0d556a --- /dev/null +++ b/dist/modules/notifications/getNotificationTexts.js @@ -0,0 +1,27 @@ +import { getGlobal } from '../payload/index.js'; +import { NOTIFICATION_FALLBACK } from './defaults.js'; +/** + * Resolves notification texts from the Notifications global, falling back to + * English defaults per field. Mirrors getConsentTexts: one read, per-field + * fallback, locale-aware. The frontend maps a submitForm result code to the + * matching text and styles it however it likes (toast, inline, banner). + */ export async function getNotificationTexts({ locale, payload }) { + const g = await getGlobal(payload, 'notifications', { + locale + }); + const f = g.form ?? {}; + const fb = NOTIFICATION_FALLBACK.form; + return { + form: { + consent: f.consent || fb.consent, + error: f.error || fb.error, + notFound: f.notFound || fb.notFound, + rateLimited: f.rateLimited || fb.rateLimited, + success: f.success || fb.success, + turnstile: f.turnstile || fb.turnstile, + validation: f.validation || fb.validation + } + }; +} + +//# sourceMappingURL=getNotificationTexts.js.map \ No newline at end of file diff --git a/dist/modules/notifications/getNotificationTexts.js.map b/dist/modules/notifications/getNotificationTexts.js.map new file mode 100644 index 0000000..e073059 --- /dev/null +++ b/dist/modules/notifications/getNotificationTexts.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/notifications/getNotificationTexts.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\nimport type { NotificationsData, NotificationTexts } from './types.js'\n\nimport { getGlobal } from '../payload/index.js'\nimport { NOTIFICATION_FALLBACK } from './defaults.js'\n\ntype GetNotificationTextsArgs = {\n /** Active locale — selects the language variant of each text. */\n locale?: string\n payload: BasePayload\n}\n\n/**\n * Resolves notification texts from the Notifications global, falling back to\n * English defaults per field. Mirrors getConsentTexts: one read, per-field\n * fallback, locale-aware. The frontend maps a submitForm result code to the\n * matching text and styles it however it likes (toast, inline, banner).\n */\nexport async function getNotificationTexts({\n locale,\n payload,\n}: GetNotificationTextsArgs): Promise {\n const g = await getGlobal(payload, 'notifications', { locale })\n\n const f = g.form ?? {}\n const fb = NOTIFICATION_FALLBACK.form\n\n return {\n form: {\n consent: f.consent || fb.consent,\n error: f.error || fb.error,\n notFound: f.notFound || fb.notFound,\n rateLimited: f.rateLimited || fb.rateLimited,\n success: f.success || fb.success,\n turnstile: f.turnstile || fb.turnstile,\n validation: f.validation || fb.validation,\n },\n }\n}\n"],"names":["getGlobal","NOTIFICATION_FALLBACK","getNotificationTexts","locale","payload","g","f","form","fb","consent","error","notFound","rateLimited","success","turnstile","validation"],"mappings":"AAIA,SAASA,SAAS,QAAQ,sBAAqB;AAC/C,SAASC,qBAAqB,QAAQ,gBAAe;AAQrD;;;;;CAKC,GACD,OAAO,eAAeC,qBAAqB,EACzCC,MAAM,EACNC,OAAO,EACkB;IACzB,MAAMC,IAAI,MAAML,UAA6BI,SAAS,iBAAiB;QAAED;IAAO;IAEhF,MAAMG,IAAID,EAAEE,IAAI,IAAI,CAAC;IACrB,MAAMC,KAAKP,sBAAsBM,IAAI;IAErC,OAAO;QACLA,MAAM;YACJE,SAASH,EAAEG,OAAO,IAAID,GAAGC,OAAO;YAChCC,OAAOJ,EAAEI,KAAK,IAAIF,GAAGE,KAAK;YAC1BC,UAAUL,EAAEK,QAAQ,IAAIH,GAAGG,QAAQ;YACnCC,aAAaN,EAAEM,WAAW,IAAIJ,GAAGI,WAAW;YAC5CC,SAASP,EAAEO,OAAO,IAAIL,GAAGK,OAAO;YAChCC,WAAWR,EAAEQ,SAAS,IAAIN,GAAGM,SAAS;YACtCC,YAAYT,EAAES,UAAU,IAAIP,GAAGO,UAAU;QAC3C;IACF;AACF"} \ No newline at end of file diff --git a/dist/modules/notifications/index.js b/dist/modules/notifications/index.js new file mode 100644 index 0000000..a9fa760 --- /dev/null +++ b/dist/modules/notifications/index.js @@ -0,0 +1,5 @@ +export { NOTIFICATION_FALLBACK } from './defaults.js'; +export { getNotificationTexts } from './getNotificationTexts.js'; +export { resolveFormMessage } from './resolveFormMessage.js'; + +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/modules/notifications/index.js.map b/dist/modules/notifications/index.js.map new file mode 100644 index 0000000..a41951c --- /dev/null +++ b/dist/modules/notifications/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/notifications/index.ts"],"sourcesContent":["export { NOTIFICATION_FALLBACK } from './defaults.js'\nexport { getNotificationTexts } from './getNotificationTexts.js'\nexport { resolveFormMessage } from './resolveFormMessage.js'\nexport type { FormNotificationTexts, NotificationsData, NotificationTexts } from './types.js'\n"],"names":["NOTIFICATION_FALLBACK","getNotificationTexts","resolveFormMessage"],"mappings":"AAAA,SAASA,qBAAqB,QAAQ,gBAAe;AACrD,SAASC,oBAAoB,QAAQ,4BAA2B;AAChE,SAASC,kBAAkB,QAAQ,0BAAyB"} \ No newline at end of file diff --git a/dist/modules/notifications/resolveFormMessage.js b/dist/modules/notifications/resolveFormMessage.js new file mode 100644 index 0000000..39b2f18 --- /dev/null +++ b/dist/modules/notifications/resolveFormMessage.js @@ -0,0 +1,35 @@ +/** + * Maps a submitForm result to the user-facing message, interpolating {field} + * for validation errors. This is the bridge the frontend uses: it gets a result + * code from submitForm and the resolved texts from getNotificationTexts, and + * this turns them into one string to display. Keeping the mapping here means the + * frontend never hard-codes messages or knows about result codes. + * + * Never surfaces raw backend/exception detail — 'error' maps to a friendly + * generic message, not the thrown error's text (which could leak internals). + */ export function resolveFormMessage(result, texts) { + if (result.success) { + return texts.success; + } + switch(result.reason){ + case 'consent': + return texts.consent; + case 'not_found': + return texts.notFound; + case 'rate_limited': + return texts.rateLimited; + case 'turnstile': + return texts.turnstile; + case 'validation': + { + // Interpolate {field} with the offending field name when present. + const field = 'field' in result && result.field ? result.field : ''; + return texts.validation.replace('{field}', field); + } + case 'error': + default: + return texts.error; + } +} + +//# sourceMappingURL=resolveFormMessage.js.map \ No newline at end of file diff --git a/dist/modules/notifications/resolveFormMessage.js.map b/dist/modules/notifications/resolveFormMessage.js.map new file mode 100644 index 0000000..09e23e2 --- /dev/null +++ b/dist/modules/notifications/resolveFormMessage.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/notifications/resolveFormMessage.ts"],"sourcesContent":["import type { SubmitFormResult } from '../forms/index.js'\nimport type { FormNotificationTexts } from './types.js'\n\n/**\n * Maps a submitForm result to the user-facing message, interpolating {field}\n * for validation errors. This is the bridge the frontend uses: it gets a result\n * code from submitForm and the resolved texts from getNotificationTexts, and\n * this turns them into one string to display. Keeping the mapping here means the\n * frontend never hard-codes messages or knows about result codes.\n *\n * Never surfaces raw backend/exception detail — 'error' maps to a friendly\n * generic message, not the thrown error's text (which could leak internals).\n */\nexport function resolveFormMessage(result: SubmitFormResult, texts: FormNotificationTexts): string {\n if (result.success) {return texts.success}\n\n switch (result.reason) {\n case 'consent':\n return texts.consent\n case 'not_found':\n return texts.notFound\n case 'rate_limited':\n return texts.rateLimited\n case 'turnstile':\n return texts.turnstile\n case 'validation': {\n // Interpolate {field} with the offending field name when present.\n const field = 'field' in result && result.field ? result.field : ''\n return texts.validation.replace('{field}', field)\n }\n case 'error':\n default:\n return texts.error\n }\n}\n"],"names":["resolveFormMessage","result","texts","success","reason","consent","notFound","rateLimited","turnstile","field","validation","replace","error"],"mappings":"AAGA;;;;;;;;;CASC,GACD,OAAO,SAASA,mBAAmBC,MAAwB,EAAEC,KAA4B;IACvF,IAAID,OAAOE,OAAO,EAAE;QAAC,OAAOD,MAAMC,OAAO;IAAA;IAEzC,OAAQF,OAAOG,MAAM;QACnB,KAAK;YACH,OAAOF,MAAMG,OAAO;QACtB,KAAK;YACH,OAAOH,MAAMI,QAAQ;QACvB,KAAK;YACH,OAAOJ,MAAMK,WAAW;QAC1B,KAAK;YACH,OAAOL,MAAMM,SAAS;QACxB,KAAK;YAAc;gBACjB,kEAAkE;gBAClE,MAAMC,QAAQ,WAAWR,UAAUA,OAAOQ,KAAK,GAAGR,OAAOQ,KAAK,GAAG;gBACjE,OAAOP,MAAMQ,UAAU,CAACC,OAAO,CAAC,WAAWF;YAC7C;QACA,KAAK;QACL;YACE,OAAOP,MAAMU,KAAK;IACtB;AACF"} \ No newline at end of file diff --git a/dist/modules/notifications/types.js b/dist/modules/notifications/types.js new file mode 100644 index 0000000..2c69489 --- /dev/null +++ b/dist/modules/notifications/types.js @@ -0,0 +1,6 @@ +/** + * Resolved notification texts, ready for the frontend. Grouped per context; + * `form` maps submitForm result codes to user-facing messages. + */ /** Raw shape read from the Notifications global (all fields optional). */ export { }; + +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/dist/modules/notifications/types.js.map b/dist/modules/notifications/types.js.map new file mode 100644 index 0000000..c44a5f1 --- /dev/null +++ b/dist/modules/notifications/types.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/notifications/types.ts"],"sourcesContent":["/**\n * Resolved notification texts, ready for the frontend. Grouped per context;\n * `form` maps submitForm result codes to user-facing messages.\n */\nexport type FormNotificationTexts = {\n success: string\n error: string\n rateLimited: string\n turnstile: string\n /** May contain the {field} placeholder — resolve with resolveValidationText. */\n validation: string\n /** Shown when a required GDPR consent checkbox was left unchecked. */\n consent: string\n notFound: string\n}\n\nexport type NotificationTexts = {\n form: FormNotificationTexts\n}\n\n/** Raw shape read from the Notifications global (all fields optional). */\nexport type NotificationsData = {\n form?: Partial\n}\n"],"names":[],"mappings":"AAAA;;;CAGC,GAiBD,wEAAwE,GACxE,WAEC"} \ No newline at end of file diff --git a/dist/modules/security/buildSecurityHeaders.d.ts b/dist/modules/security/buildSecurityHeaders.d.ts new file mode 100644 index 0000000..2c0b7a1 --- /dev/null +++ b/dist/modules/security/buildSecurityHeaders.d.ts @@ -0,0 +1,70 @@ +/** + * A single HTTP header, in the shape Next.js next.config headers() expects. + */ +export type SecurityHeader = { + key: string; + value: string; +}; +export type BuildSecurityHeadersArgs = { + /** + * Extra headers to append or override. Same-key entries replace the default, + * so you can e.g. add your project's Content-Security-Policy here — CSP is + * intentionally NOT a default because it depends on the project's own + * domains (scripts, images, fonts, analytics). Keep CSP in your project. + */ + additional?: SecurityHeader[]; + /** + * X-Frame-Options value. 'DENY' (default) blocks all framing; 'SAMEORIGIN' + * allows same-origin framing. Note: CSP frame-ancestors supersedes this in + * modern browsers, but X-Frame-Options is kept for older ones. Set to null + * to omit (e.g. if you set frame-ancestors in your project CSP). + */ + frameOptions?: 'DENY' | 'SAMEORIGIN' | null; + /** + * Enable HSTS (Strict-Transport-Security). Only takes effect over HTTPS, and + * tells browsers to force HTTPS for `maxAge` seconds. Default true. Turn OFF + * in local/dev over plain HTTP, or you may lock the browser to https on + * localhost. Set the env guard in your next.config (see docs). + */ + hsts?: boolean; + /** Add includeSubDomains to HSTS. Default true. */ + hstsIncludeSubDomains?: boolean; + /** HSTS max-age in seconds. Default 63072000 (2 years), the common baseline. */ + hstsMaxAge?: number; + /** Add preload to HSTS (only if you'll submit to the preload list). Default false. */ + hstsPreload?: boolean; + /** + * Permissions-Policy. Default disables camera, microphone, geolocation. Pass + * your own string to override, or null to omit. + */ + permissionsPolicy?: null | string; + /** Referrer-Policy. Default 'strict-origin-when-cross-origin' (browser default, explicit). */ + referrerPolicy?: null | string; +}; +/** + * Builds the generic, project-independent security headers every site should + * send: HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, + * Permissions-Policy. These are identical across projects, so the plugin owns + * the boilerplate; the client spreads the result into next.config's headers(). + * + * Content-Security-Policy is deliberately excluded: a useful CSP enumerates the + * exact domains a project loads from (its CDN, analytics, embeds), so it can't + * be generic without being either too loose (useless) or too strict (breaks the + * site). Add your project's CSP via `additional`. + * + * @example + * // next.config.ts + * import { buildSecurityHeaders } from '@intecion/ipal-kit' + * const securityHeaders = buildSecurityHeaders({ + * hsts: process.env.NODE_ENV === 'production', // off in dev over http + * additional: [ + * { key: 'Content-Security-Policy', value: "default-src 'self'; ..." }, + * ], + * }) + * const nextConfig = { + * async headers() { + * return [{ source: '/:path*', headers: securityHeaders }] + * }, + * } + */ +export declare function buildSecurityHeaders(args?: BuildSecurityHeadersArgs): SecurityHeader[]; diff --git a/dist/modules/security/buildSecurityHeaders.js b/dist/modules/security/buildSecurityHeaders.js new file mode 100644 index 0000000..3bfadec --- /dev/null +++ b/dist/modules/security/buildSecurityHeaders.js @@ -0,0 +1,81 @@ +/** + * A single HTTP header, in the shape Next.js next.config headers() expects. + */ /** + * Builds the generic, project-independent security headers every site should + * send: HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, + * Permissions-Policy. These are identical across projects, so the plugin owns + * the boilerplate; the client spreads the result into next.config's headers(). + * + * Content-Security-Policy is deliberately excluded: a useful CSP enumerates the + * exact domains a project loads from (its CDN, analytics, embeds), so it can't + * be generic without being either too loose (useless) or too strict (breaks the + * site). Add your project's CSP via `additional`. + * + * @example + * // next.config.ts + * import { buildSecurityHeaders } from '@intecion/ipal-kit' + * const securityHeaders = buildSecurityHeaders({ + * hsts: process.env.NODE_ENV === 'production', // off in dev over http + * additional: [ + * { key: 'Content-Security-Policy', value: "default-src 'self'; ..." }, + * ], + * }) + * const nextConfig = { + * async headers() { + * return [{ source: '/:path*', headers: securityHeaders }] + * }, + * } + */ export function buildSecurityHeaders(args = {}) { + const { additional = [], frameOptions = 'DENY', hsts = true, hstsIncludeSubDomains = true, hstsMaxAge = 63072000, hstsPreload = false, permissionsPolicy = 'camera=(), microphone=(), geolocation=()', referrerPolicy = 'strict-origin-when-cross-origin' } = args; + const headers = []; + if (hsts) { + const parts = [ + `max-age=${hstsMaxAge}` + ]; + if (hstsIncludeSubDomains) { + parts.push('includeSubDomains'); + } + if (hstsPreload) { + parts.push('preload'); + } + headers.push({ + key: 'Strict-Transport-Security', + value: parts.join('; ') + }); + } + if (frameOptions) { + headers.push({ + key: 'X-Frame-Options', + value: frameOptions + }); + } + // Prevents MIME-type sniffing — always safe, no project specifics. + headers.push({ + key: 'X-Content-Type-Options', + value: 'nosniff' + }); + if (referrerPolicy) { + headers.push({ + key: 'Referrer-Policy', + value: referrerPolicy + }); + } + if (permissionsPolicy) { + headers.push({ + key: 'Permissions-Policy', + value: permissionsPolicy + }); + } + // Merge additional: same-key entries override the defaults above. + for (const extra of additional){ + const i = headers.findIndex((h)=>h.key.toLowerCase() === extra.key.toLowerCase()); + if (i >= 0) { + headers[i] = extra; + } else { + headers.push(extra); + } + } + return headers; +} + +//# sourceMappingURL=buildSecurityHeaders.js.map \ No newline at end of file diff --git a/dist/modules/security/buildSecurityHeaders.js.map b/dist/modules/security/buildSecurityHeaders.js.map new file mode 100644 index 0000000..dd8b4e8 --- /dev/null +++ b/dist/modules/security/buildSecurityHeaders.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/security/buildSecurityHeaders.ts"],"sourcesContent":["/**\n * A single HTTP header, in the shape Next.js next.config headers() expects.\n */\nexport type SecurityHeader = { key: string; value: string }\n\nexport type BuildSecurityHeadersArgs = {\n /**\n * Extra headers to append or override. Same-key entries replace the default,\n * so you can e.g. add your project's Content-Security-Policy here — CSP is\n * intentionally NOT a default because it depends on the project's own\n * domains (scripts, images, fonts, analytics). Keep CSP in your project.\n */\n additional?: SecurityHeader[]\n /**\n * X-Frame-Options value. 'DENY' (default) blocks all framing; 'SAMEORIGIN'\n * allows same-origin framing. Note: CSP frame-ancestors supersedes this in\n * modern browsers, but X-Frame-Options is kept for older ones. Set to null\n * to omit (e.g. if you set frame-ancestors in your project CSP).\n */\n frameOptions?: 'DENY' | 'SAMEORIGIN' | null\n /**\n * Enable HSTS (Strict-Transport-Security). Only takes effect over HTTPS, and\n * tells browsers to force HTTPS for `maxAge` seconds. Default true. Turn OFF\n * in local/dev over plain HTTP, or you may lock the browser to https on\n * localhost. Set the env guard in your next.config (see docs).\n */\n hsts?: boolean\n /** Add includeSubDomains to HSTS. Default true. */\n hstsIncludeSubDomains?: boolean\n /** HSTS max-age in seconds. Default 63072000 (2 years), the common baseline. */\n hstsMaxAge?: number\n /** Add preload to HSTS (only if you'll submit to the preload list). Default false. */\n hstsPreload?: boolean\n /**\n * Permissions-Policy. Default disables camera, microphone, geolocation. Pass\n * your own string to override, or null to omit.\n */\n permissionsPolicy?: null | string\n /** Referrer-Policy. Default 'strict-origin-when-cross-origin' (browser default, explicit). */\n referrerPolicy?: null | string\n}\n\n/**\n * Builds the generic, project-independent security headers every site should\n * send: HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy,\n * Permissions-Policy. These are identical across projects, so the plugin owns\n * the boilerplate; the client spreads the result into next.config's headers().\n *\n * Content-Security-Policy is deliberately excluded: a useful CSP enumerates the\n * exact domains a project loads from (its CDN, analytics, embeds), so it can't\n * be generic without being either too loose (useless) or too strict (breaks the\n * site). Add your project's CSP via `additional`.\n *\n * @example\n * // next.config.ts\n * import { buildSecurityHeaders } from '@intecion/ipal-kit'\n * const securityHeaders = buildSecurityHeaders({\n * hsts: process.env.NODE_ENV === 'production', // off in dev over http\n * additional: [\n * { key: 'Content-Security-Policy', value: \"default-src 'self'; ...\" },\n * ],\n * })\n * const nextConfig = {\n * async headers() {\n * return [{ source: '/:path*', headers: securityHeaders }]\n * },\n * }\n */\nexport function buildSecurityHeaders(args: BuildSecurityHeadersArgs = {}): SecurityHeader[] {\n const {\n additional = [],\n frameOptions = 'DENY',\n hsts = true,\n hstsIncludeSubDomains = true,\n hstsMaxAge = 63072000,\n hstsPreload = false,\n permissionsPolicy = 'camera=(), microphone=(), geolocation=()',\n referrerPolicy = 'strict-origin-when-cross-origin',\n } = args\n\n const headers: SecurityHeader[] = []\n\n if (hsts) {\n const parts = [`max-age=${hstsMaxAge}`]\n if (hstsIncludeSubDomains) {parts.push('includeSubDomains')}\n if (hstsPreload) {parts.push('preload')}\n headers.push({ key: 'Strict-Transport-Security', value: parts.join('; ') })\n }\n\n if (frameOptions) {\n headers.push({ key: 'X-Frame-Options', value: frameOptions })\n }\n\n // Prevents MIME-type sniffing — always safe, no project specifics.\n headers.push({ key: 'X-Content-Type-Options', value: 'nosniff' })\n\n if (referrerPolicy) {\n headers.push({ key: 'Referrer-Policy', value: referrerPolicy })\n }\n\n if (permissionsPolicy) {\n headers.push({ key: 'Permissions-Policy', value: permissionsPolicy })\n }\n\n // Merge additional: same-key entries override the defaults above.\n for (const extra of additional) {\n const i = headers.findIndex((h) => h.key.toLowerCase() === extra.key.toLowerCase())\n if (i >= 0) {headers[i] = extra}\n else {headers.push(extra)}\n }\n\n return headers\n}\n"],"names":["buildSecurityHeaders","args","additional","frameOptions","hsts","hstsIncludeSubDomains","hstsMaxAge","hstsPreload","permissionsPolicy","referrerPolicy","headers","parts","push","key","value","join","extra","i","findIndex","h","toLowerCase"],"mappings":"AAAA;;CAEC,GAwCD;;;;;;;;;;;;;;;;;;;;;;;;;CAyBC,GACD,OAAO,SAASA,qBAAqBC,OAAiC,CAAC,CAAC;IACtE,MAAM,EACJC,aAAa,EAAE,EACfC,eAAe,MAAM,EACrBC,OAAO,IAAI,EACXC,wBAAwB,IAAI,EAC5BC,aAAa,QAAQ,EACrBC,cAAc,KAAK,EACnBC,oBAAoB,0CAA0C,EAC9DC,iBAAiB,iCAAiC,EACnD,GAAGR;IAEJ,MAAMS,UAA4B,EAAE;IAEpC,IAAIN,MAAM;QACR,MAAMO,QAAQ;YAAC,CAAC,QAAQ,EAAEL,YAAY;SAAC;QACvC,IAAID,uBAAuB;YAACM,MAAMC,IAAI,CAAC;QAAoB;QAC3D,IAAIL,aAAa;YAACI,MAAMC,IAAI,CAAC;QAAU;QACvCF,QAAQE,IAAI,CAAC;YAAEC,KAAK;YAA6BC,OAAOH,MAAMI,IAAI,CAAC;QAAM;IAC3E;IAEA,IAAIZ,cAAc;QAChBO,QAAQE,IAAI,CAAC;YAAEC,KAAK;YAAmBC,OAAOX;QAAa;IAC7D;IAEA,mEAAmE;IACnEO,QAAQE,IAAI,CAAC;QAAEC,KAAK;QAA0BC,OAAO;IAAU;IAE/D,IAAIL,gBAAgB;QAClBC,QAAQE,IAAI,CAAC;YAAEC,KAAK;YAAmBC,OAAOL;QAAe;IAC/D;IAEA,IAAID,mBAAmB;QACrBE,QAAQE,IAAI,CAAC;YAAEC,KAAK;YAAsBC,OAAON;QAAkB;IACrE;IAEA,kEAAkE;IAClE,KAAK,MAAMQ,SAASd,WAAY;QAC9B,MAAMe,IAAIP,QAAQQ,SAAS,CAAC,CAACC,IAAMA,EAAEN,GAAG,CAACO,WAAW,OAAOJ,MAAMH,GAAG,CAACO,WAAW;QAChF,IAAIH,KAAK,GAAG;YAACP,OAAO,CAACO,EAAE,GAAGD;QAAK,OAC1B;YAACN,QAAQE,IAAI,CAACI;QAAM;IAC3B;IAEA,OAAON;AACT"} \ No newline at end of file diff --git a/dist/modules/security/index.d.ts b/dist/modules/security/index.d.ts new file mode 100644 index 0000000..581d855 --- /dev/null +++ b/dist/modules/security/index.d.ts @@ -0,0 +1,2 @@ +export { buildSecurityHeaders } from './buildSecurityHeaders.js'; +export type { BuildSecurityHeadersArgs, SecurityHeader } from './buildSecurityHeaders.js'; diff --git a/dist/modules/security/index.js b/dist/modules/security/index.js new file mode 100644 index 0000000..8595f67 --- /dev/null +++ b/dist/modules/security/index.js @@ -0,0 +1,3 @@ +export { buildSecurityHeaders } from './buildSecurityHeaders.js'; + +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/modules/security/index.js.map b/dist/modules/security/index.js.map new file mode 100644 index 0000000..7bcc753 --- /dev/null +++ b/dist/modules/security/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/security/index.ts"],"sourcesContent":["export { buildSecurityHeaders } from './buildSecurityHeaders.js'\nexport type { BuildSecurityHeadersArgs, SecurityHeader } from './buildSecurityHeaders.js'\n"],"names":["buildSecurityHeaders"],"mappings":"AAAA,SAASA,oBAAoB,QAAQ,4BAA2B"} \ No newline at end of file diff --git a/src/modules/security/buildSecurityHeaders.ts b/src/modules/security/buildSecurityHeaders.ts new file mode 100644 index 0000000..994c5d1 --- /dev/null +++ b/src/modules/security/buildSecurityHeaders.ts @@ -0,0 +1,113 @@ +/** + * A single HTTP header, in the shape Next.js next.config headers() expects. + */ +export type SecurityHeader = { key: string; value: string } + +export type BuildSecurityHeadersArgs = { + /** + * Extra headers to append or override. Same-key entries replace the default, + * so you can e.g. add your project's Content-Security-Policy here — CSP is + * intentionally NOT a default because it depends on the project's own + * domains (scripts, images, fonts, analytics). Keep CSP in your project. + */ + additional?: SecurityHeader[] + /** + * X-Frame-Options value. 'DENY' (default) blocks all framing; 'SAMEORIGIN' + * allows same-origin framing. Note: CSP frame-ancestors supersedes this in + * modern browsers, but X-Frame-Options is kept for older ones. Set to null + * to omit (e.g. if you set frame-ancestors in your project CSP). + */ + frameOptions?: 'DENY' | 'SAMEORIGIN' | null + /** + * Enable HSTS (Strict-Transport-Security). Only takes effect over HTTPS, and + * tells browsers to force HTTPS for `maxAge` seconds. Default true. Turn OFF + * in local/dev over plain HTTP, or you may lock the browser to https on + * localhost. Set the env guard in your next.config (see docs). + */ + hsts?: boolean + /** Add includeSubDomains to HSTS. Default true. */ + hstsIncludeSubDomains?: boolean + /** HSTS max-age in seconds. Default 63072000 (2 years), the common baseline. */ + hstsMaxAge?: number + /** Add preload to HSTS (only if you'll submit to the preload list). Default false. */ + hstsPreload?: boolean + /** + * Permissions-Policy. Default disables camera, microphone, geolocation. Pass + * your own string to override, or null to omit. + */ + permissionsPolicy?: null | string + /** Referrer-Policy. Default 'strict-origin-when-cross-origin' (browser default, explicit). */ + referrerPolicy?: null | string +} + +/** + * Builds the generic, project-independent security headers every site should + * send: HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, + * Permissions-Policy. These are identical across projects, so the plugin owns + * the boilerplate; the client spreads the result into next.config's headers(). + * + * Content-Security-Policy is deliberately excluded: a useful CSP enumerates the + * exact domains a project loads from (its CDN, analytics, embeds), so it can't + * be generic without being either too loose (useless) or too strict (breaks the + * site). Add your project's CSP via `additional`. + * + * @example + * // next.config.ts + * import { buildSecurityHeaders } from '@intecion/ipal-kit' + * const securityHeaders = buildSecurityHeaders({ + * hsts: process.env.NODE_ENV === 'production', // off in dev over http + * additional: [ + * { key: 'Content-Security-Policy', value: "default-src 'self'; ..." }, + * ], + * }) + * const nextConfig = { + * async headers() { + * return [{ source: '/:path*', headers: securityHeaders }] + * }, + * } + */ +export function buildSecurityHeaders(args: BuildSecurityHeadersArgs = {}): SecurityHeader[] { + const { + additional = [], + frameOptions = 'DENY', + hsts = true, + hstsIncludeSubDomains = true, + hstsMaxAge = 63072000, + hstsPreload = false, + permissionsPolicy = 'camera=(), microphone=(), geolocation=()', + referrerPolicy = 'strict-origin-when-cross-origin', + } = args + + const headers: SecurityHeader[] = [] + + if (hsts) { + const parts = [`max-age=${hstsMaxAge}`] + if (hstsIncludeSubDomains) {parts.push('includeSubDomains')} + if (hstsPreload) {parts.push('preload')} + headers.push({ key: 'Strict-Transport-Security', value: parts.join('; ') }) + } + + if (frameOptions) { + headers.push({ key: 'X-Frame-Options', value: frameOptions }) + } + + // Prevents MIME-type sniffing — always safe, no project specifics. + headers.push({ key: 'X-Content-Type-Options', value: 'nosniff' }) + + if (referrerPolicy) { + headers.push({ key: 'Referrer-Policy', value: referrerPolicy }) + } + + if (permissionsPolicy) { + headers.push({ key: 'Permissions-Policy', value: permissionsPolicy }) + } + + // Merge additional: same-key entries override the defaults above. + for (const extra of additional) { + const i = headers.findIndex((h) => h.key.toLowerCase() === extra.key.toLowerCase()) + if (i >= 0) {headers[i] = extra} + else {headers.push(extra)} + } + + return headers +} diff --git a/src/modules/security/index.ts b/src/modules/security/index.ts new file mode 100644 index 0000000..dae0b94 --- /dev/null +++ b/src/modules/security/index.ts @@ -0,0 +1,2 @@ +export { buildSecurityHeaders } from './buildSecurityHeaders.js' +export type { BuildSecurityHeadersArgs, SecurityHeader } from './buildSecurityHeaders.js'