@10x-media/form-builder 0.1.0-beta.14 → 0.1.0-beta.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/collections/formSubmissions.js +40 -20
  3. package/dist/collections/formSubmissions.js.map +1 -1
  4. package/dist/collections/forms.js +20 -0
  5. package/dist/collections/forms.js.map +1 -1
  6. package/dist/exports/react.d.ts +2 -1
  7. package/dist/exports/rsc.d.ts +2 -1
  8. package/dist/exports/rsc.js +4 -3
  9. package/dist/form/pollState.d.ts +2 -1
  10. package/dist/form/pollState.js.map +1 -1
  11. package/dist/form/toFormDocument.d.ts +1 -0
  12. package/dist/form/toFormDocument.js +1 -0
  13. package/dist/form/toFormDocument.js.map +1 -1
  14. package/dist/form/types.d.ts +2 -1
  15. package/dist/index.d.ts +2 -1
  16. package/dist/index.js +5 -4
  17. package/dist/index.js.map +1 -1
  18. package/dist/poll/votes/aggregateFromVotes.js +2 -1
  19. package/dist/poll/votes/aggregateFromVotes.js.map +1 -1
  20. package/dist/poll/votes/answerValues.js +16 -0
  21. package/dist/poll/votes/answerValues.js.map +1 -0
  22. package/dist/poll/votes/voteTallyHook.js +31 -13
  23. package/dist/poll/votes/voteTallyHook.js.map +1 -1
  24. package/dist/react/Form.d.ts +3 -1
  25. package/dist/react/Form.js +5 -1
  26. package/dist/react/Form.js.map +1 -1
  27. package/dist/react/FormResults.d.ts +4 -2
  28. package/dist/react/FormResults.js +21 -8
  29. package/dist/react/FormResults.js.map +1 -1
  30. package/dist/react/Poll.d.ts +9 -0
  31. package/dist/react/Poll.js +40 -10
  32. package/dist/react/Poll.js.map +1 -1
  33. package/dist/spam/spamGuard.js +2 -1
  34. package/dist/spam/spamGuard.js.map +1 -1
  35. package/dist/submissions/resolveVotedSubmission.d.ts +27 -0
  36. package/dist/submissions/resolveVotedSubmission.js +51 -0
  37. package/dist/submissions/resolveVotedSubmission.js.map +1 -0
  38. package/dist/submissions/validateSubmission.js +17 -6
  39. package/dist/submissions/validateSubmission.js.map +1 -1
  40. package/dist/submissions/verifyContext.js +1 -1
  41. package/dist/submissions/voteChangeEndpoint.js +100 -0
  42. package/dist/submissions/voteChangeEndpoint.js.map +1 -0
  43. package/dist/submissions/votedCookie.d.ts +2 -0
  44. package/dist/submissions/votedCookie.js +53 -6
  45. package/dist/submissions/votedCookie.js.map +1 -1
  46. package/dist/translations/clientRuntimeKeys.js +3 -1
  47. package/dist/translations/clientRuntimeKeys.js.map +1 -1
  48. package/dist/translations/de.js +5 -0
  49. package/dist/translations/de.js.map +1 -1
  50. package/dist/translations/en.js +5 -0
  51. package/dist/translations/en.js.map +1 -1
  52. package/dist/translations/keys.d.ts +5 -0
  53. package/dist/translations/keys.js +5 -0
  54. package/dist/translations/keys.js.map +1 -1
  55. package/dist/validation/builtin/notAlreadySubmitted.js +3 -1
  56. package/dist/validation/builtin/notAlreadySubmitted.js.map +1 -1
  57. package/package.json +2 -2
@@ -1 +1 @@
1
- {"version":3,"file":"spamGuard.js","names":[],"sources":["../../src/spam/spamGuard.ts"],"sourcesContent":["import { APIError, type CollectionBeforeValidateHook, type PayloadRequest } from 'payload'\nimport type { SubmissionValue } from '../submissions/types'\nimport { keys } from '../translations/keys'\nimport { asTranslate } from '../translations/server'\nimport { firstHop } from './clientIp'\nimport { HONEYPOT_VALUE_KEY, IDENTITY_CONTEXT_KEY } from './constants'\nimport { extractReservedValues, isHoneypotTripped } from './reserved'\nimport type { ResolvedSpamConfig } from './types'\n\nlet warnedRateLimitSkipped = false\n\n/** Warn once per process when rate limiting is configured but skipped for lack of a request identity. */\nconst warnRateLimitSkippedOnce = (req: PayloadRequest): void => {\n\tif (warnedRateLimitSkipped) {\n\t\treturn\n\t}\n\twarnedRateLimitSkipped = true\n\treq.payload.logger?.warn(\n\t\t'@10x-media/form-builder: submission rate limiting is configured but skipped for a request with no resolvable identity (no user and no client IP). Set spam.ipHeader for your proxy or provide a custom spam.identify.'\n\t)\n}\n\n/**\n * Submissions spam guard, prepended before `validateSubmission` in `beforeValidate` so it rejects before\n * the form load + field validation. On create it: resolves the request identity once (stashing it on\n * `req.context` for upload-ownership verification downstream); rate-limits per form + identity (429,\n * fail-open when identity is null); strips reserved `values` entries (honeypot, captcha token); rejects a\n * filled honeypot with a generic error; verifies a configured captcha; and writes a server-authoritative\n * `meta` (timestamp + spam signal, with opt-in ip/ua). App-level rate limiting is defense-in-depth that\n * complements edge/CDN/WAF limiting, not a DoS replacement.\n */\nexport const buildSpamGuard =\n\t(spam: ResolvedSpamConfig): CollectionBeforeValidateHook =>\n\tasync ({ data, operation, req }) => {\n\t\tif (operation !== 'create' || !data) {\n\t\t\treturn data\n\t\t}\n\t\tconst t = asTranslate(req.i18n.t)\n\t\t// Honeypot + captcha are anti-bot measures for the anonymous public path; an authenticated request\n\t\t// (admin panel, logged-in API client) is already gated by auth and never carries a decoy/token, so\n\t\t// checking it would falsely reject legitimate authed creates. Rate-limit + ownership still apply.\n\t\tconst authenticated = Boolean(req.user)\n\n\t\tconst identity = await spam.identify(req)\n\t\tif (identity != null) {\n\t\t\treq.context[IDENTITY_CONTEXT_KEY] = identity\n\t\t}\n\n\t\tlet rateLimitState: 'enforced' | 'skipped-no-identity' | 'disabled'\n\t\tif (spam.rateLimit === false) {\n\t\t\trateLimitState = 'disabled'\n\t\t} else if (identity == null) {\n\t\t\t// Fail open (an unidentifiable submitter cannot be keyed) but never silently: record it on\n\t\t\t// meta.spam and warn once, so a deployment with no proxy or a misconfigured ipHeader is visible.\n\t\t\trateLimitState = 'skipped-no-identity'\n\t\t\twarnRateLimitSkippedOnce(req)\n\t\t} else {\n\t\t\tconst formKey = data.form != null ? String(data.form) : 'unknown'\n\t\t\tconst { ok } = await spam.rateLimit.limiter.check({\n\t\t\t\tkey: `submissions:${formKey}:${identity}`,\n\t\t\t\tmax: spam.rateLimit.max,\n\t\t\t\twindow: spam.rateLimit.window,\n\t\t\t\treq,\n\t\t\t})\n\t\t\tif (!ok) {\n\t\t\t\tthrow new APIError(t(keys.spamRateLimited), 429)\n\t\t\t}\n\t\t\trateLimitState = 'enforced'\n\t\t}\n\n\t\t// The decoy rides a fixed reserved key (not its cosmetic DOM name), so a real field sharing that\n\t\t// name is never stripped or mistaken for the honeypot. `spam.honeypot.fieldName` is now only the\n\t\t// client's DOM-input name hint and no longer participates in server matching.\n\t\tconst honeypotKey = spam.honeypot === false ? null : HONEYPOT_VALUE_KEY\n\t\tconst values = (data.values as SubmissionValue[] | undefined) ?? []\n\t\tconst { cleaned, honeypot, captchaToken } = extractReservedValues(values, honeypotKey)\n\t\tdata.values = cleaned\n\n\t\tif (!authenticated && honeypotKey !== null && isHoneypotTripped(honeypot)) {\n\t\t\tthrow new APIError(t(keys.spamRejected), 400)\n\t\t}\n\n\t\tconst captchaChecked = Boolean(spam.captcha) && !authenticated\n\t\tif (spam.captcha && !authenticated) {\n\t\t\tconst passed =\n\t\t\t\ttypeof captchaToken === 'string' && captchaToken.length > 0\n\t\t\t\t\t? await spam.captcha.verify({ token: captchaToken, req }).catch(() => false)\n\t\t\t\t\t: false\n\t\t\tif (!passed) {\n\t\t\t\tthrow new APIError(t(keys.spamCaptchaFailed), 400)\n\t\t\t}\n\t\t}\n\n\t\tconst serverMeta: Record<string, unknown> = {\n\t\t\tat: new Date().toISOString(),\n\t\t\tspam: { captcha: captchaChecked ? 'passed' : 'skipped', rateLimit: rateLimitState },\n\t\t}\n\t\tif (spam.metadata.ip) {\n\t\t\tconst ip = firstHop(req.headers, spam.ipHeader)\n\t\t\tif (ip) {\n\t\t\t\tserverMeta.ip = ip\n\t\t\t}\n\t\t}\n\t\tif (spam.metadata.ua) {\n\t\t\tconst ua = req.headers?.get('user-agent')\n\t\t\tif (ua) {\n\t\t\t\tserverMeta.ua = ua\n\t\t\t}\n\t\t}\n\t\t// Preserve an authenticated (trusted) caller's own `meta` keys, with server fields winning. Anonymous\n\t\t// `meta` is never trusted, so it is discarded.\n\t\tconst clientMeta =\n\t\t\tauthenticated && data.meta != null && typeof data.meta === 'object'\n\t\t\t\t? (data.meta as Record<string, unknown>)\n\t\t\t\t: {}\n\t\tdata.meta = { ...clientMeta, ...serverMeta }\n\n\t\treturn data\n\t}\n"],"mappings":";;;;;;;AASA,IAAI,yBAAyB;;AAG7B,MAAM,4BAA4B,QAA8B;CAC/D,IAAI,wBACH;CAED,yBAAyB;CACzB,IAAI,QAAQ,QAAQ,KACnB,uNACD;AACD;;;;;;;;;;AAWA,MAAa,kBACX,SACD,OAAO,EAAE,MAAM,WAAW,UAAU;CACnC,IAAI,cAAc,YAAY,CAAC,MAC9B,OAAO;CAER,MAAM,IAAI,YAAY,IAAI,KAAK,CAAC;CAIhC,MAAM,gBAAgB,QAAQ,IAAI,IAAI;CAEtC,MAAM,WAAW,MAAM,KAAK,SAAS,GAAG;CACxC,IAAI,YAAY,MACf,IAAI,QAAQ,wBAAwB;CAGrC,IAAI;CACJ,IAAI,KAAK,cAAc,OACtB,iBAAiB;MACX,IAAI,YAAY,MAAM;EAG5B,iBAAiB;EACjB,yBAAyB,GAAG;CAC7B,OAAO;EACN,MAAM,UAAU,KAAK,QAAQ,OAAO,OAAO,KAAK,IAAI,IAAI;EACxD,MAAM,EAAE,OAAO,MAAM,KAAK,UAAU,QAAQ,MAAM;GACjD,KAAK,eAAe,QAAQ,GAAG;GAC/B,KAAK,KAAK,UAAU;GACpB,QAAQ,KAAK,UAAU;GACvB;EACD,CAAC;EACD,IAAI,CAAC,IACJ,MAAM,IAAI,SAAS,EAAE,KAAK,eAAe,GAAG,GAAG;EAEhD,iBAAiB;CAClB;CAKA,MAAM,cAAc,KAAK,aAAa,QAAQ,OAAO;CAErD,MAAM,EAAE,SAAS,UAAU,iBAAiB,sBAD5B,KAAK,UAA4C,CAAC,GACQ,WAAW;CACrF,KAAK,SAAS;CAEd,IAAI,CAAC,iBAAiB,gBAAgB,QAAQ,kBAAkB,QAAQ,GACvE,MAAM,IAAI,SAAS,EAAE,KAAK,YAAY,GAAG,GAAG;CAG7C,MAAM,iBAAiB,QAAQ,KAAK,OAAO,KAAK,CAAC;CACjD,IAAI,KAAK,WAAW,CAAC;MAKhB,EAHH,OAAO,iBAAiB,YAAY,aAAa,SAAS,IACvD,MAAM,KAAK,QAAQ,OAAO;GAAE,OAAO;GAAc;EAAI,CAAC,EAAE,YAAY,KAAK,IACzE,QAEH,MAAM,IAAI,SAAS,EAAE,KAAK,iBAAiB,GAAG,GAAG;CAAA;CAInD,MAAM,aAAsC;EAC3C,qBAAI,IAAI,KAAK,GAAE,YAAY;EAC3B,MAAM;GAAE,SAAS,iBAAiB,WAAW;GAAW,WAAW;EAAe;CACnF;CACA,IAAI,KAAK,SAAS,IAAI;EACrB,MAAM,KAAK,SAAS,IAAI,SAAS,KAAK,QAAQ;EAC9C,IAAI,IACH,WAAW,KAAK;CAElB;CACA,IAAI,KAAK,SAAS,IAAI;EACrB,MAAM,KAAK,IAAI,SAAS,IAAI,YAAY;EACxC,IAAI,IACH,WAAW,KAAK;CAElB;CAOA,KAAK,OAAO;EAAE,GAHb,iBAAiB,KAAK,QAAQ,QAAQ,OAAO,KAAK,SAAS,WACvD,KAAK,OACN,CAAC;EACwB,GAAG;CAAW;CAE3C,OAAO;AACR"}
1
+ {"version":3,"file":"spamGuard.js","names":[],"sources":["../../src/spam/spamGuard.ts"],"sourcesContent":["import { APIError, type CollectionBeforeValidateHook, type PayloadRequest } from 'payload'\nimport type { SubmissionValue } from '../submissions/types'\nimport { voteChangeTargetOf } from '../submissions/votedCookie'\nimport { keys } from '../translations/keys'\nimport { asTranslate } from '../translations/server'\nimport { firstHop } from './clientIp'\nimport { HONEYPOT_VALUE_KEY, IDENTITY_CONTEXT_KEY } from './constants'\nimport { extractReservedValues, isHoneypotTripped } from './reserved'\nimport type { ResolvedSpamConfig } from './types'\n\nlet warnedRateLimitSkipped = false\n\n/** Warn once per process when rate limiting is configured but skipped for lack of a request identity. */\nconst warnRateLimitSkippedOnce = (req: PayloadRequest): void => {\n\tif (warnedRateLimitSkipped) {\n\t\treturn\n\t}\n\twarnedRateLimitSkipped = true\n\treq.payload.logger?.warn(\n\t\t'@10x-media/form-builder: submission rate limiting is configured but skipped for a request with no resolvable identity (no user and no client IP). Set spam.ipHeader for your proxy or provide a custom spam.identify.'\n\t)\n}\n\n/**\n * Submissions spam guard, prepended before `validateSubmission` in `beforeValidate` so it rejects before\n * the form load + field validation. On create it: resolves the request identity once (stashing it on\n * `req.context` for upload-ownership verification downstream); rate-limits per form + identity (429,\n * fail-open when identity is null); strips reserved `values` entries (honeypot, captcha token); rejects a\n * filled honeypot with a generic error; verifies a configured captcha; and writes a server-authoritative\n * `meta` (timestamp + spam signal, with opt-in ip/ua). App-level rate limiting is defense-in-depth that\n * complements edge/CDN/WAF limiting, not a DoS replacement.\n */\nexport const buildSpamGuard =\n\t(spam: ResolvedSpamConfig): CollectionBeforeValidateHook =>\n\tasync ({ data, operation, req }) => {\n\t\t// A flagged vote-change update is an anonymous public write like a create, so the full guard\n\t\t// (rate limit, honeypot, captcha, metadata) applies to it too; other updates stay exempt.\n\t\tconst guarded =\n\t\t\toperation === 'create' || (operation === 'update' && voteChangeTargetOf(req) !== undefined)\n\t\tif (!guarded || !data) {\n\t\t\treturn data\n\t\t}\n\t\tconst t = asTranslate(req.i18n.t)\n\t\t// Honeypot + captcha are anti-bot measures for the anonymous public path; an authenticated request\n\t\t// (admin panel, logged-in API client) is already gated by auth and never carries a decoy/token, so\n\t\t// checking it would falsely reject legitimate authed creates. Rate-limit + ownership still apply.\n\t\tconst authenticated = Boolean(req.user)\n\n\t\tconst identity = await spam.identify(req)\n\t\tif (identity != null) {\n\t\t\treq.context[IDENTITY_CONTEXT_KEY] = identity\n\t\t}\n\n\t\tlet rateLimitState: 'enforced' | 'skipped-no-identity' | 'disabled'\n\t\tif (spam.rateLimit === false) {\n\t\t\trateLimitState = 'disabled'\n\t\t} else if (identity == null) {\n\t\t\t// Fail open (an unidentifiable submitter cannot be keyed) but never silently: record it on\n\t\t\t// meta.spam and warn once, so a deployment with no proxy or a misconfigured ipHeader is visible.\n\t\t\trateLimitState = 'skipped-no-identity'\n\t\t\twarnRateLimitSkippedOnce(req)\n\t\t} else {\n\t\t\tconst formKey = data.form != null ? String(data.form) : 'unknown'\n\t\t\tconst { ok } = await spam.rateLimit.limiter.check({\n\t\t\t\tkey: `submissions:${formKey}:${identity}`,\n\t\t\t\tmax: spam.rateLimit.max,\n\t\t\t\twindow: spam.rateLimit.window,\n\t\t\t\treq,\n\t\t\t})\n\t\t\tif (!ok) {\n\t\t\t\tthrow new APIError(t(keys.spamRateLimited), 429)\n\t\t\t}\n\t\t\trateLimitState = 'enforced'\n\t\t}\n\n\t\t// The decoy rides a fixed reserved key (not its cosmetic DOM name), so a real field sharing that\n\t\t// name is never stripped or mistaken for the honeypot. `spam.honeypot.fieldName` is now only the\n\t\t// client's DOM-input name hint and no longer participates in server matching.\n\t\tconst honeypotKey = spam.honeypot === false ? null : HONEYPOT_VALUE_KEY\n\t\tconst values = (data.values as SubmissionValue[] | undefined) ?? []\n\t\tconst { cleaned, honeypot, captchaToken } = extractReservedValues(values, honeypotKey)\n\t\tdata.values = cleaned\n\n\t\tif (!authenticated && honeypotKey !== null && isHoneypotTripped(honeypot)) {\n\t\t\tthrow new APIError(t(keys.spamRejected), 400)\n\t\t}\n\n\t\tconst captchaChecked = Boolean(spam.captcha) && !authenticated\n\t\tif (spam.captcha && !authenticated) {\n\t\t\tconst passed =\n\t\t\t\ttypeof captchaToken === 'string' && captchaToken.length > 0\n\t\t\t\t\t? await spam.captcha.verify({ token: captchaToken, req }).catch(() => false)\n\t\t\t\t\t: false\n\t\t\tif (!passed) {\n\t\t\t\tthrow new APIError(t(keys.spamCaptchaFailed), 400)\n\t\t\t}\n\t\t}\n\n\t\tconst serverMeta: Record<string, unknown> = {\n\t\t\tat: new Date().toISOString(),\n\t\t\tspam: { captcha: captchaChecked ? 'passed' : 'skipped', rateLimit: rateLimitState },\n\t\t}\n\t\tif (spam.metadata.ip) {\n\t\t\tconst ip = firstHop(req.headers, spam.ipHeader)\n\t\t\tif (ip) {\n\t\t\t\tserverMeta.ip = ip\n\t\t\t}\n\t\t}\n\t\tif (spam.metadata.ua) {\n\t\t\tconst ua = req.headers?.get('user-agent')\n\t\t\tif (ua) {\n\t\t\t\tserverMeta.ua = ua\n\t\t\t}\n\t\t}\n\t\t// Preserve an authenticated (trusted) caller's own `meta` keys, with server fields winning. Anonymous\n\t\t// `meta` is never trusted, so it is discarded.\n\t\tconst clientMeta =\n\t\t\tauthenticated && data.meta != null && typeof data.meta === 'object'\n\t\t\t\t? (data.meta as Record<string, unknown>)\n\t\t\t\t: {}\n\t\tdata.meta = { ...clientMeta, ...serverMeta }\n\n\t\treturn data\n\t}\n"],"mappings":";;;;;;;;AAUA,IAAI,yBAAyB;;AAG7B,MAAM,4BAA4B,QAA8B;CAC/D,IAAI,wBACH;CAED,yBAAyB;CACzB,IAAI,QAAQ,QAAQ,KACnB,uNACD;AACD;;;;;;;;;;AAWA,MAAa,kBACX,SACD,OAAO,EAAE,MAAM,WAAW,UAAU;CAKnC,IAAI,EADH,cAAc,YAAa,cAAc,YAAY,mBAAmB,GAAG,MAAM,KAAA,MAClE,CAAC,MAChB,OAAO;CAER,MAAM,IAAI,YAAY,IAAI,KAAK,CAAC;CAIhC,MAAM,gBAAgB,QAAQ,IAAI,IAAI;CAEtC,MAAM,WAAW,MAAM,KAAK,SAAS,GAAG;CACxC,IAAI,YAAY,MACf,IAAI,QAAQ,wBAAwB;CAGrC,IAAI;CACJ,IAAI,KAAK,cAAc,OACtB,iBAAiB;MACX,IAAI,YAAY,MAAM;EAG5B,iBAAiB;EACjB,yBAAyB,GAAG;CAC7B,OAAO;EACN,MAAM,UAAU,KAAK,QAAQ,OAAO,OAAO,KAAK,IAAI,IAAI;EACxD,MAAM,EAAE,OAAO,MAAM,KAAK,UAAU,QAAQ,MAAM;GACjD,KAAK,eAAe,QAAQ,GAAG;GAC/B,KAAK,KAAK,UAAU;GACpB,QAAQ,KAAK,UAAU;GACvB;EACD,CAAC;EACD,IAAI,CAAC,IACJ,MAAM,IAAI,SAAS,EAAE,KAAK,eAAe,GAAG,GAAG;EAEhD,iBAAiB;CAClB;CAKA,MAAM,cAAc,KAAK,aAAa,QAAQ,OAAO;CAErD,MAAM,EAAE,SAAS,UAAU,iBAAiB,sBAD5B,KAAK,UAA4C,CAAC,GACQ,WAAW;CACrF,KAAK,SAAS;CAEd,IAAI,CAAC,iBAAiB,gBAAgB,QAAQ,kBAAkB,QAAQ,GACvE,MAAM,IAAI,SAAS,EAAE,KAAK,YAAY,GAAG,GAAG;CAG7C,MAAM,iBAAiB,QAAQ,KAAK,OAAO,KAAK,CAAC;CACjD,IAAI,KAAK,WAAW,CAAC;MAKhB,EAHH,OAAO,iBAAiB,YAAY,aAAa,SAAS,IACvD,MAAM,KAAK,QAAQ,OAAO;GAAE,OAAO;GAAc;EAAI,CAAC,EAAE,YAAY,KAAK,IACzE,QAEH,MAAM,IAAI,SAAS,EAAE,KAAK,iBAAiB,GAAG,GAAG;CAAA;CAInD,MAAM,aAAsC;EAC3C,qBAAI,IAAI,KAAK,GAAE,YAAY;EAC3B,MAAM;GAAE,SAAS,iBAAiB,WAAW;GAAW,WAAW;EAAe;CACnF;CACA,IAAI,KAAK,SAAS,IAAI;EACrB,MAAM,KAAK,SAAS,IAAI,SAAS,KAAK,QAAQ;EAC9C,IAAI,IACH,WAAW,KAAK;CAElB;CACA,IAAI,KAAK,SAAS,IAAI;EACrB,MAAM,KAAK,IAAI,SAAS,IAAI,YAAY;EACxC,IAAI,IACH,WAAW,KAAK;CAElB;CAOA,KAAK,OAAO;EAAE,GAHb,iBAAiB,KAAK,QAAQ,QAAQ,OAAO,KAAK,SAAS,WACvD,KAAK,OACN,CAAC;EACwB,GAAG;CAAW;CAE3C,OAAO;AACR"}
@@ -0,0 +1,27 @@
1
+ import { Payload } from "payload";
2
+
3
+ //#region src/submissions/resolveVotedSubmission.d.ts
4
+ /** The voter's current vote for a poll, resolved server-side from the httpOnly voted cookie. */
5
+ type VotedSubmission = {
6
+ submissionId: string; /** The stored answer of the poll results field exactly as submitted (string, or string[] for a multi-select); use it to prefill the form for a vote change. */
7
+ value: unknown; /** The same answer normalized to non-empty strings; use it to highlight the voter's pick in results. */
8
+ pick: string[];
9
+ };
10
+ /**
11
+ * Resolve which submission (and which pick) a browser's voted cookie identifies for a form. The
12
+ * cookie is httpOnly, so this is the server-side read: an SSR host passes the incoming request's
13
+ * `Cookie` header (e.g. Next's `(await headers()).get('cookie')`) and hands the result to
14
+ * `<Poll currentVote hasVoted>`. Returns null whenever the cookie is absent, is the legacy `1`
15
+ * marker, fails signature verification, or names a submission that no longer exists or belongs to
16
+ * a different form, so a null simply means "treat this visitor as not having voted". Works for any
17
+ * poll whose cookie carries a signed id (set for `allowChange` polls), even if `allowChange` was
18
+ * later turned off.
19
+ */
20
+ declare const resolveVotedSubmission: (args: {
21
+ payload: Payload;
22
+ cookieHeader: string | null | undefined;
23
+ formId: number | string;
24
+ }) => Promise<VotedSubmission | null>;
25
+ //#endregion
26
+ export { VotedSubmission, resolveVotedSubmission };
27
+ //# sourceMappingURL=resolveVotedSubmission.d.ts.map
@@ -0,0 +1,51 @@
1
+ import { pollConfigOf } from "../form/pollState.js";
2
+ import { formIdOf } from "./formIdOf.js";
3
+ import { answerValues } from "../poll/votes/answerValues.js";
4
+ import { votedSubmissionIdFromCookie } from "./votedCookie.js";
5
+ //#region src/submissions/resolveVotedSubmission.ts
6
+ const FORM_SUBMISSIONS_SLUG = "form-submissions";
7
+ const FORMS_SLUG = "forms";
8
+ /**
9
+ * Resolve which submission (and which pick) a browser's voted cookie identifies for a form. The
10
+ * cookie is httpOnly, so this is the server-side read: an SSR host passes the incoming request's
11
+ * `Cookie` header (e.g. Next's `(await headers()).get('cookie')`) and hands the result to
12
+ * `<Poll currentVote hasVoted>`. Returns null whenever the cookie is absent, is the legacy `1`
13
+ * marker, fails signature verification, or names a submission that no longer exists or belongs to
14
+ * a different form, so a null simply means "treat this visitor as not having voted". Works for any
15
+ * poll whose cookie carries a signed id (set for `allowChange` polls), even if `allowChange` was
16
+ * later turned off.
17
+ */
18
+ const resolveVotedSubmission = async (args) => {
19
+ const { payload, cookieHeader, formId } = args;
20
+ const submissionId = votedSubmissionIdFromCookie(cookieHeader, formId, payload.secret);
21
+ if (submissionId == null) return null;
22
+ const submission = await payload.findByID({
23
+ collection: FORM_SUBMISSIONS_SLUG,
24
+ id: submissionId,
25
+ depth: 0,
26
+ overrideAccess: true
27
+ }).catch(() => null);
28
+ if (submission == null) return null;
29
+ const stored = submission;
30
+ if (String(formIdOf(stored.form) ?? "") !== String(formId)) return null;
31
+ if (stored.status != null && stored.status !== "complete") return null;
32
+ const form = await payload.findByID({
33
+ collection: FORMS_SLUG,
34
+ id: formId,
35
+ depth: 0,
36
+ overrideAccess: true
37
+ }).catch(() => null);
38
+ const poll = form?.pollEnabled === true ? pollConfigOf(form.poll) : void 0;
39
+ const resultsField = typeof poll?.resultsField === "string" && poll.resultsField.length > 0 ? poll.resultsField : void 0;
40
+ if (!resultsField) return null;
41
+ const entry = Array.isArray(stored.values) ? stored.values.find((row) => row.field === resultsField) : void 0;
42
+ return {
43
+ submissionId: String(submission.id),
44
+ value: entry?.value,
45
+ pick: answerValues(stored.values, resultsField)
46
+ };
47
+ };
48
+ //#endregion
49
+ export { resolveVotedSubmission };
50
+
51
+ //# sourceMappingURL=resolveVotedSubmission.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolveVotedSubmission.js","names":[],"sources":["../../src/submissions/resolveVotedSubmission.ts"],"sourcesContent":["import type { Payload } from 'payload'\nimport { pollConfigOf } from '../form/pollState'\nimport { answerValues } from '../poll/votes/answerValues'\nimport { formIdOf } from './formIdOf'\nimport { votedSubmissionIdFromCookie } from './votedCookie'\n\nconst FORM_SUBMISSIONS_SLUG = 'form-submissions'\nconst FORMS_SLUG = 'forms'\n\n/** The voter's current vote for a poll, resolved server-side from the httpOnly voted cookie. */\nexport type VotedSubmission = {\n\tsubmissionId: string\n\t/** The stored answer of the poll results field exactly as submitted (string, or string[] for a multi-select); use it to prefill the form for a vote change. */\n\tvalue: unknown\n\t/** The same answer normalized to non-empty strings; use it to highlight the voter's pick in results. */\n\tpick: string[]\n}\n\n/**\n * Resolve which submission (and which pick) a browser's voted cookie identifies for a form. The\n * cookie is httpOnly, so this is the server-side read: an SSR host passes the incoming request's\n * `Cookie` header (e.g. Next's `(await headers()).get('cookie')`) and hands the result to\n * `<Poll currentVote hasVoted>`. Returns null whenever the cookie is absent, is the legacy `1`\n * marker, fails signature verification, or names a submission that no longer exists or belongs to\n * a different form, so a null simply means \"treat this visitor as not having voted\". Works for any\n * poll whose cookie carries a signed id (set for `allowChange` polls), even if `allowChange` was\n * later turned off.\n */\nexport const resolveVotedSubmission = async (args: {\n\tpayload: Payload\n\tcookieHeader: string | null | undefined\n\tformId: number | string\n}): Promise<VotedSubmission | null> => {\n\tconst { payload, cookieHeader, formId } = args\n\tconst submissionId = votedSubmissionIdFromCookie(cookieHeader, formId, payload.secret)\n\tif (submissionId == null) {\n\t\treturn null\n\t}\n\tconst submission = await payload\n\t\t.findByID({\n\t\t\tcollection: FORM_SUBMISSIONS_SLUG,\n\t\t\tid: submissionId,\n\t\t\tdepth: 0,\n\t\t\toverrideAccess: true,\n\t\t})\n\t\t.catch(() => null)\n\tif (submission == null) {\n\t\treturn null\n\t}\n\tconst stored = submission as { form?: unknown; status?: unknown; values?: unknown }\n\tif (String(formIdOf(stored.form) ?? '') !== String(formId)) {\n\t\treturn null\n\t}\n\tif (stored.status != null && stored.status !== 'complete') {\n\t\treturn null\n\t}\n\tconst form = await payload\n\t\t.findByID({ collection: FORMS_SLUG, id: formId, depth: 0, overrideAccess: true })\n\t\t.catch(() => null)\n\tconst poll = form?.pollEnabled === true ? pollConfigOf(form.poll) : undefined\n\tconst resultsField =\n\t\ttypeof poll?.resultsField === 'string' && poll.resultsField.length > 0\n\t\t\t? poll.resultsField\n\t\t\t: undefined\n\tif (!resultsField) {\n\t\treturn null\n\t}\n\tconst entry = Array.isArray(stored.values)\n\t\t? (stored.values as { field?: unknown; value?: unknown }[]).find(\n\t\t\t\t(row) => row.field === resultsField\n\t\t\t)\n\t\t: undefined\n\treturn {\n\t\tsubmissionId: String(submission.id),\n\t\tvalue: entry?.value,\n\t\tpick: answerValues(stored.values, resultsField),\n\t}\n}\n"],"mappings":";;;;;AAMA,MAAM,wBAAwB;AAC9B,MAAM,aAAa;;;;;;;;;;;AAqBnB,MAAa,yBAAyB,OAAO,SAIN;CACtC,MAAM,EAAE,SAAS,cAAc,WAAW;CAC1C,MAAM,eAAe,4BAA4B,cAAc,QAAQ,QAAQ,MAAM;CACrF,IAAI,gBAAgB,MACnB,OAAO;CAER,MAAM,aAAa,MAAM,QACvB,SAAS;EACT,YAAY;EACZ,IAAI;EACJ,OAAO;EACP,gBAAgB;CACjB,CAAC,EACA,YAAY,IAAI;CAClB,IAAI,cAAc,MACjB,OAAO;CAER,MAAM,SAAS;CACf,IAAI,OAAO,SAAS,OAAO,IAAI,KAAK,EAAE,MAAM,OAAO,MAAM,GACxD,OAAO;CAER,IAAI,OAAO,UAAU,QAAQ,OAAO,WAAW,YAC9C,OAAO;CAER,MAAM,OAAO,MAAM,QACjB,SAAS;EAAE,YAAY;EAAY,IAAI;EAAQ,OAAO;EAAG,gBAAgB;CAAK,CAAC,EAC/E,YAAY,IAAI;CAClB,MAAM,OAAO,MAAM,gBAAgB,OAAO,aAAa,KAAK,IAAI,IAAI,KAAA;CACpE,MAAM,eACL,OAAO,MAAM,iBAAiB,YAAY,KAAK,aAAa,SAAS,IAClE,KAAK,eACL,KAAA;CACJ,IAAI,CAAC,cACJ,OAAO;CAER,MAAM,QAAQ,MAAM,QAAQ,OAAO,MAAM,IACrC,OAAO,OAAkD,MACzD,QAAQ,IAAI,UAAU,YACxB,IACC,KAAA;CACH,OAAO;EACN,cAAc,OAAO,WAAW,EAAE;EAClC,OAAO,OAAO;EACd,MAAM,aAAa,OAAO,QAAQ,YAAY;CAC/C;AACD"}
@@ -1,30 +1,38 @@
1
1
  import { keys } from "../translations/keys.js";
2
2
  import { asFieldTranslate, asTranslate } from "../translations/server.js";
3
3
  import { resolveConsentEntries } from "../consent/resolveConsentEntries.js";
4
+ import { isPollClosed, pollConfigOf } from "../form/pollState.js";
4
5
  import { calcExpressionOf } from "../calc/computeCalcFields.js";
5
6
  import { resolveCalcContext } from "../calc/resolveCalcContext.js";
6
- import { isPollClosed, pollConfigOf } from "../form/pollState.js";
7
7
  import { resolveEffectivePollOptions } from "../poll/effectivePollOptions.js";
8
8
  import { FORMS_SLUG } from "../collections/forms.js";
9
+ import { formIdOf } from "./formIdOf.js";
10
+ import { POLL_CONTEXT_KEY, voteChangeTargetOf } from "./votedCookie.js";
9
11
  import { IDENTITY_CONTEXT_KEY } from "../spam/constants.js";
10
12
  import { applyPollOptions } from "../poll/applyPollOptions.js";
11
13
  import { runSubmission } from "./runSubmission.js";
12
- import { POLL_CONTEXT_KEY } from "./votedCookie.js";
13
14
  import { FORM_SUBMISSIONS_SLUG } from "../collections/formSubmissions.js";
14
15
  import { APIError, ValidationError } from "payload";
15
16
  //#region src/submissions/validateSubmission.ts
16
17
  /**
17
- * Server-authoritative submission validation. On create it loads the referenced form, re-runs every
18
+ * Server-authoritative submission validation. On create (and on a vote-change update flagged by the
19
+ * vote-submit endpoint) it loads the referenced form, re-runs every
18
20
  * field's required check, intrinsic validator, and declarative rules through `runSubmission`, threading
19
21
  * `req`/`payload` so server-only async rules can hit the DB, and throws a Payload `ValidationError` with
20
22
  * per-field paths on any failure. The client is never trusted.
21
23
  * Consent fields are captured into `result.consent` (array of proofs, one per visible consent field),
22
24
  * built from the host's sources re-resolved here rather than from anything the form or client carries.
23
25
  */
24
- const validateSubmission = ({ registry, ruleRegistry, calcSources, calcFunctions, consentSources, consentSnapshot, uploadSlug, pollSourceRegistry, strictUploadOwnership }) => async ({ data, operation, req }) => {
25
- if (operation !== "create" || !data) return data;
26
+ const validateSubmission = ({ registry, ruleRegistry, calcSources, calcFunctions, consentSources, consentSnapshot, uploadSlug, pollSourceRegistry, strictUploadOwnership }) => async ({ data, operation, originalDoc, req }) => {
27
+ const changeTarget = operation === "update" ? voteChangeTargetOf(req) : void 0;
28
+ if (operation !== "create" && changeTarget === void 0 || !data) return data;
26
29
  const formId = data.form;
27
30
  if (formId == null) return data;
31
+ if (changeTarget !== void 0) {
32
+ const originalFormId = originalDoc?.form;
33
+ const boundFormId = originalFormId != null ? formIdOf(originalFormId) : null;
34
+ if (boundFormId == null || String(boundFormId) !== String(formId)) throw new APIError("form-builder: a vote change cannot move a submission across forms", 400);
35
+ }
28
36
  const form = await req.payload.findByID({
29
37
  collection: FORMS_SLUG,
30
38
  id: formId,
@@ -34,7 +42,10 @@ const validateSubmission = ({ registry, ruleRegistry, calcSources, calcFunctions
34
42
  });
35
43
  const poll = pollConfigOf(form.poll);
36
44
  const pollEnabled = form.pollEnabled === true;
37
- req.context[POLL_CONTEXT_KEY] = pollEnabled;
45
+ req.context[POLL_CONTEXT_KEY] = {
46
+ pollEnabled,
47
+ allowChange: poll?.allowChange === true
48
+ };
38
49
  if (pollEnabled && isPollClosed(poll)) throw new APIError(asTranslate(req.i18n.t)(keys.pollClosed), 403);
39
50
  let fields = form.fields ?? [];
40
51
  const incoming = data.values ?? [];
@@ -1 +1 @@
1
- {"version":3,"file":"validateSubmission.js","names":[],"sources":["../../src/submissions/validateSubmission.ts"],"sourcesContent":["import { APIError, type CollectionBeforeValidateHook, ValidationError } from 'payload'\nimport { calcExpressionOf } from '../calc/computeCalcFields'\nimport type { CalcResolved } from '../calc/evaluate'\nimport type { CalcFunction, CalcSource } from '../calc/registry'\nimport { resolveCalcContext } from '../calc/resolveCalcContext'\nimport { FORM_SUBMISSIONS_SLUG } from '../collections/formSubmissions'\nimport { FORMS_SLUG } from '../collections/forms'\nimport type { ConsentSnapshotMode } from '../consent/captureConsent'\nimport { resolveConsentEntries } from '../consent/resolveConsentEntries'\nimport type { ConsentSourceEntry, ConsentSourcesResolver } from '../consent/types'\nimport type { FieldTypeRegistry } from '../fields/registry'\nimport { isPollClosed, pollConfigOf } from '../form/pollState'\nimport { applyPollOptions } from '../poll/applyPollOptions'\nimport type { PollOption } from '../poll/definePollOptionSource'\nimport { resolveEffectivePollOptions } from '../poll/effectivePollOptions'\nimport type { PollOptionSourceRegistry } from '../poll/registry'\nimport { IDENTITY_CONTEXT_KEY } from '../spam/constants'\nimport { keys } from '../translations/keys'\nimport { asFieldTranslate, asTranslate } from '../translations/server'\nimport type { ValidationRuleRegistry } from '../validation/registry'\nimport { runSubmission } from './runSubmission'\nimport type { FormFieldInstance, SubmissionValue } from './types'\nimport { POLL_CONTEXT_KEY } from './votedCookie'\n\nexport type ValidateSubmissionArgs = {\n\tregistry: FieldTypeRegistry\n\truleRegistry: ValidationRuleRegistry\n\t/** Registered calc sources (plugin option `calc.sources`); re-resolved fresh per submission. */\n\tcalcSources?: Record<string, CalcSource>\n\t/** Registered calc functions (plugin option `calc.functions`); threaded into calc evaluation. */\n\tcalcFunctions?: Record<string, CalcFunction>\n\t/** The host's consent sources resolver (plugin option `consent.sources`); absent when no sources are configured. */\n\tconsentSources?: ConsentSourcesResolver\n\t/** What each consent proof snapshots of the agreed wording (plugin option `consent.snapshot`). */\n\tconsentSnapshot?: ConsentSnapshotMode\n\t/** The plugin-configured uploads collection slug; absent when uploads are disabled. */\n\tuploadSlug?: string\n\t/** Registered poll option sources; a form's configured `optionSource` resolves through this at validation time. */\n\tpollSourceRegistry?: PollOptionSourceRegistry\n\t/** Plugin `spam.uploadOwnership: 'strict'`: reject an owned upload when the submitter is unidentifiable. */\n\tstrictUploadOwnership?: boolean\n}\n\n/**\n * Server-authoritative submission validation. On create it loads the referenced form, re-runs every\n * field's required check, intrinsic validator, and declarative rules through `runSubmission`, threading\n * `req`/`payload` so server-only async rules can hit the DB, and throws a Payload `ValidationError` with\n * per-field paths on any failure. The client is never trusted.\n * Consent fields are captured into `result.consent` (array of proofs, one per visible consent field),\n * built from the host's sources re-resolved here rather than from anything the form or client carries.\n */\nexport const validateSubmission =\n\t({\n\t\tregistry,\n\t\truleRegistry,\n\t\tcalcSources,\n\t\tcalcFunctions,\n\t\tconsentSources,\n\t\tconsentSnapshot,\n\t\tuploadSlug,\n\t\tpollSourceRegistry,\n\t\tstrictUploadOwnership,\n\t}: ValidateSubmissionArgs): CollectionBeforeValidateHook =>\n\tasync ({ data, operation, req }) => {\n\t\tif (operation !== 'create' || !data) {\n\t\t\treturn data\n\t\t}\n\t\tconst formId = data.form\n\t\tif (formId == null) {\n\t\t\treturn data\n\t\t}\n\n\t\tconst form = await req.payload.findByID({\n\t\t\tcollection: FORMS_SLUG,\n\t\t\tid: formId as string | number,\n\t\t\tdepth: 0,\n\t\t\tlocale: req.locale,\n\t\t\treq,\n\t\t})\n\n\t\t// Stash whether the form is poll-enabled (its top-level `pollEnabled` flag) so the voted-cookie\n\t\t// afterChange hook can skip a second form fetch on the same request.\n\t\tconst poll = pollConfigOf(form.poll)\n\t\tconst pollEnabled = form.pollEnabled === true\n\t\treq.context[POLL_CONTEXT_KEY] = pollEnabled\n\n\t\t// Form-level lifecycle guard, before any field work: a closed poll accepts no submissions,\n\t\t// regardless of what the client rendered.\n\t\tif (pollEnabled && isPollClosed(poll)) {\n\t\t\tthrow new APIError(asTranslate(req.i18n.t)(keys.pollClosed), 403)\n\t\t}\n\n\t\tlet fields = ((form.fields as FormFieldInstance[] | undefined) ?? []) as FormFieldInstance[]\n\t\tconst incoming = ((data.values as SubmissionValue[] | undefined) ?? []) as SubmissionValue[]\n\t\tconst locale = req.locale ?? 'en'\n\t\tconst t = asFieldTranslate(req.i18n.t)\n\n\t\t// With a resolved choice set for the results field (a poll `optionSource`, or the field type's\n\t\t// own `resolveOptions`) the resolved values are the only accepted answers: options are injected\n\t\t// into the field instance (so the select's membership check and the stored option labels use\n\t\t// them) and membership is also enforced directly, so an empty resolution or a non-select results\n\t\t// field still fails closed. A resolve failure rejects the whole submission rather than skipping\n\t\t// the check. Static authored polls are unaffected: their field validates against its own options\n\t\t// through the normal field pipeline.\n\t\tconst resultsField =\n\t\t\tpollEnabled && typeof poll?.resultsField === 'string' && poll.resultsField.length > 0\n\t\t\t\t? poll.resultsField\n\t\t\t\t: undefined\n\t\tconst resultsInstance = resultsField\n\t\t\t? fields.find((instance) => instance.name === resultsField)\n\t\t\t: undefined\n\t\tconst usesResolver =\n\t\t\t(typeof poll?.optionSource === 'string' && poll.optionSource.length > 0) ||\n\t\t\tBoolean(resultsInstance && registry.get(resultsInstance.blockType)?.resolveOptions)\n\t\tif (resultsField && usesResolver) {\n\t\t\tlet resolved: PollOption[]\n\t\t\ttry {\n\t\t\t\tresolved = await resolveEffectivePollOptions({\n\t\t\t\t\tpayload: req.payload,\n\t\t\t\t\treq,\n\t\t\t\t\tform,\n\t\t\t\t\tsources: pollSourceRegistry ?? new Map(),\n\t\t\t\t\tfieldTypes: registry,\n\t\t\t\t})\n\t\t\t} catch {\n\t\t\t\tthrow new APIError(asTranslate(req.i18n.t)(keys.pollOptionsUnavailable), 503)\n\t\t\t}\n\t\t\tfields = applyPollOptions(fields, resultsField, resolved)\n\t\t\tconst answer = incoming.find((entry) => entry.field === resultsField)?.value\n\t\t\tconst isAnswered = answer != null && answer !== ''\n\t\t\tif (isAnswered && !resolved.some((option) => option.value === answer)) {\n\t\t\t\tthrow new ValidationError(\n\t\t\t\t\t{\n\t\t\t\t\t\tcollection: FORM_SUBMISSIONS_SLUG,\n\t\t\t\t\t\terrors: [\n\t\t\t\t\t\t\t{ path: resultsField, message: asTranslate(req.i18n.t)(keys.validationSelect) },\n\t\t\t\t\t\t],\n\t\t\t\t\t},\n\t\t\t\t\treq.t\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\t\tconst expectedOwner =\n\t\t\ttypeof req.context?.[IDENTITY_CONTEXT_KEY] === 'string'\n\t\t\t\t? (req.context[IDENTITY_CONTEXT_KEY] as string)\n\t\t\t\t: undefined\n\n\t\t// Every consent proof is built from the source as it reads right now, not from anything the\n\t\t// form or the client carries, so a resolver failure rejects the submission rather than\n\t\t// recording an agreement to a statement the server cannot vouch for.\n\t\tlet consentEntries: ConsentSourceEntry[] = []\n\t\tif (consentSources && fields.some((field) => field.blockType === 'consent')) {\n\t\t\ttry {\n\t\t\t\tconsentEntries = await resolveConsentEntries({\n\t\t\t\t\tpayload: req.payload,\n\t\t\t\t\treq,\n\t\t\t\t\tform,\n\t\t\t\t\tsources: consentSources,\n\t\t\t\t})\n\t\t\t} catch {\n\t\t\t\tthrow new APIError(asTranslate(req.i18n.t)(keys.consentSourcesUnavailable), 503)\n\t\t\t}\n\t\t}\n\n\t\t// Calc extension values re-resolve fresh here, never trusting the render-time embedding, and a\n\t\t// resolver failure rejects the submission: a calc total is money math, so an outage must not\n\t\t// silently evaluate to 0 (the render path fails open instead; see `calcAfterRead`). Surfaced as\n\t\t// a translated 503 (the consent-sources precedent above), with the cause logged server-side\n\t\t// rather than leaked to the anonymous caller.\n\t\tlet calcResolved: CalcResolved | undefined\n\t\tif (fields.some((instance) => calcExpressionOf(instance) !== undefined)) {\n\t\t\tlet resolved: CalcResolved\n\t\t\ttry {\n\t\t\t\tresolved = await resolveCalcContext({\n\t\t\t\t\tfields,\n\t\t\t\t\tsources: calcSources ?? {},\n\t\t\t\t\t// Double cast: a host's generated Form interface has no index signature, so the direct\n\t\t\t\t\t// cast fails under a consumer tsconfig even though the shape is a plain document.\n\t\t\t\t\tform: form as unknown as { id: number | string } & Record<string, unknown>,\n\t\t\t\t\tpayload: req.payload,\n\t\t\t\t\treq,\n\t\t\t\t})\n\t\t\t} catch (error) {\n\t\t\t\treq.payload.logger?.error(\n\t\t\t\t\t`@10x-media/form-builder: calc source resolution failed for form ${String(formId)}: ${\n\t\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t\t}`\n\t\t\t\t)\n\t\t\t\tthrow new APIError(asTranslate(req.i18n.t)(keys.calcSourcesUnavailable), 503)\n\t\t\t}\n\t\t\tconst functions =\n\t\t\t\tcalcFunctions && Object.keys(calcFunctions).length > 0\n\t\t\t\t\t? Object.fromEntries(\n\t\t\t\t\t\t\tObject.entries(calcFunctions).map(([name, definition]) => [name, definition.apply])\n\t\t\t\t\t\t)\n\t\t\t\t\t: undefined\n\t\t\tcalcResolved = { ...resolved, ...(functions ? { functions } : {}) }\n\t\t}\n\n\t\tconst result = await runSubmission({\n\t\t\tfields,\n\t\t\tvalues: incoming,\n\t\t\tcalcResolved,\n\t\t\tregistry,\n\t\t\truleRegistry,\n\t\t\tconsentEntries,\n\t\t\tconsentSnapshot,\n\t\t\tlocale,\n\t\t\tt,\n\t\t\toperation: 'create',\n\t\t\treq,\n\t\t\tpayload: req.payload,\n\t\t\tformId: formId as number | string,\n\t\t\tuploadSlug,\n\t\t\texpectedOwner,\n\t\t\tstrictUploadOwnership,\n\t\t})\n\n\t\tif (result.errors.length > 0) {\n\t\t\tthrow new ValidationError({ collection: FORM_SUBMISSIONS_SLUG, errors: result.errors }, req.t)\n\t\t}\n\n\t\tdata.values = result.values\n\t\tdata.descriptors = result.descriptors\n\t\tdata.consent = result.consent.length > 0 ? result.consent : undefined\n\t\tdata.locale = locale\n\t\t// Unauthenticated submits are always 'complete'. A client-supplied 'partial' would\n\t\t// silently skip all post-submit actions and events. Authenticated callers (e.g. an\n\t\t// admin draft-save flow) may set status themselves.\n\t\tif (!req.user) {\n\t\t\tdata.status = 'complete'\n\t\t}\n\t\treturn data\n\t}\n"],"mappings":"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"}
1
+ {"version":3,"file":"validateSubmission.js","names":[],"sources":["../../src/submissions/validateSubmission.ts"],"sourcesContent":["import { APIError, type CollectionBeforeValidateHook, ValidationError } from 'payload'\nimport { calcExpressionOf } from '../calc/computeCalcFields'\nimport type { CalcResolved } from '../calc/evaluate'\nimport type { CalcFunction, CalcSource } from '../calc/registry'\nimport { resolveCalcContext } from '../calc/resolveCalcContext'\nimport { FORM_SUBMISSIONS_SLUG } from '../collections/formSubmissions'\nimport { FORMS_SLUG } from '../collections/forms'\nimport type { ConsentSnapshotMode } from '../consent/captureConsent'\nimport { resolveConsentEntries } from '../consent/resolveConsentEntries'\nimport type { ConsentSourceEntry, ConsentSourcesResolver } from '../consent/types'\nimport type { FieldTypeRegistry } from '../fields/registry'\nimport { isPollClosed, pollConfigOf } from '../form/pollState'\nimport { applyPollOptions } from '../poll/applyPollOptions'\nimport type { PollOption } from '../poll/definePollOptionSource'\nimport { resolveEffectivePollOptions } from '../poll/effectivePollOptions'\nimport type { PollOptionSourceRegistry } from '../poll/registry'\nimport { IDENTITY_CONTEXT_KEY } from '../spam/constants'\nimport { keys } from '../translations/keys'\nimport { asFieldTranslate, asTranslate } from '../translations/server'\nimport type { ValidationRuleRegistry } from '../validation/registry'\nimport { formIdOf } from './formIdOf'\nimport { runSubmission } from './runSubmission'\nimport type { FormFieldInstance, SubmissionValue } from './types'\nimport { POLL_CONTEXT_KEY, type PollContextState, voteChangeTargetOf } from './votedCookie'\n\nexport type ValidateSubmissionArgs = {\n\tregistry: FieldTypeRegistry\n\truleRegistry: ValidationRuleRegistry\n\t/** Registered calc sources (plugin option `calc.sources`); re-resolved fresh per submission. */\n\tcalcSources?: Record<string, CalcSource>\n\t/** Registered calc functions (plugin option `calc.functions`); threaded into calc evaluation. */\n\tcalcFunctions?: Record<string, CalcFunction>\n\t/** The host's consent sources resolver (plugin option `consent.sources`); absent when no sources are configured. */\n\tconsentSources?: ConsentSourcesResolver\n\t/** What each consent proof snapshots of the agreed wording (plugin option `consent.snapshot`). */\n\tconsentSnapshot?: ConsentSnapshotMode\n\t/** The plugin-configured uploads collection slug; absent when uploads are disabled. */\n\tuploadSlug?: string\n\t/** Registered poll option sources; a form's configured `optionSource` resolves through this at validation time. */\n\tpollSourceRegistry?: PollOptionSourceRegistry\n\t/** Plugin `spam.uploadOwnership: 'strict'`: reject an owned upload when the submitter is unidentifiable. */\n\tstrictUploadOwnership?: boolean\n}\n\n/**\n * Server-authoritative submission validation. On create (and on a vote-change update flagged by the\n * vote-submit endpoint) it loads the referenced form, re-runs every\n * field's required check, intrinsic validator, and declarative rules through `runSubmission`, threading\n * `req`/`payload` so server-only async rules can hit the DB, and throws a Payload `ValidationError` with\n * per-field paths on any failure. The client is never trusted.\n * Consent fields are captured into `result.consent` (array of proofs, one per visible consent field),\n * built from the host's sources re-resolved here rather than from anything the form or client carries.\n */\nexport const validateSubmission =\n\t({\n\t\tregistry,\n\t\truleRegistry,\n\t\tcalcSources,\n\t\tcalcFunctions,\n\t\tconsentSources,\n\t\tconsentSnapshot,\n\t\tuploadSlug,\n\t\tpollSourceRegistry,\n\t\tstrictUploadOwnership,\n\t}: ValidateSubmissionArgs): CollectionBeforeValidateHook =>\n\tasync ({ data, operation, originalDoc, req }) => {\n\t\t// Updates run the same create-grade pipeline only when the vote-change endpoint flagged the\n\t\t// request; every other update (host server code with overrideAccess) keeps today's behavior.\n\t\tconst changeTarget = operation === 'update' ? voteChangeTargetOf(req) : undefined\n\t\tif ((operation !== 'create' && changeTarget === undefined) || !data) {\n\t\t\treturn data\n\t\t}\n\t\tconst formId = data.form\n\t\tif (formId == null) {\n\t\t\treturn data\n\t\t}\n\t\tif (changeTarget !== undefined) {\n\t\t\t// Fail closed: an unresolvable stored form binding is as disqualifying as a mismatched one.\n\t\t\tconst originalFormId = (originalDoc as { form?: unknown } | undefined)?.form\n\t\t\tconst boundFormId = originalFormId != null ? formIdOf(originalFormId) : null\n\t\t\tif (boundFormId == null || String(boundFormId) !== String(formId)) {\n\t\t\t\tthrow new APIError('form-builder: a vote change cannot move a submission across forms', 400)\n\t\t\t}\n\t\t}\n\n\t\tconst form = await req.payload.findByID({\n\t\t\tcollection: FORMS_SLUG,\n\t\t\tid: formId as string | number,\n\t\t\tdepth: 0,\n\t\t\tlocale: req.locale,\n\t\t\treq,\n\t\t})\n\n\t\t// Stash the form's poll state so the voted-cookie afterChange hook can skip a second form\n\t\t// fetch on the same request.\n\t\tconst poll = pollConfigOf(form.poll)\n\t\tconst pollEnabled = form.pollEnabled === true\n\t\treq.context[POLL_CONTEXT_KEY] = {\n\t\t\tpollEnabled,\n\t\t\tallowChange: poll?.allowChange === true,\n\t\t} satisfies PollContextState\n\n\t\t// Form-level lifecycle guard, before any field work: a closed poll accepts no submissions,\n\t\t// regardless of what the client rendered.\n\t\tif (pollEnabled && isPollClosed(poll)) {\n\t\t\tthrow new APIError(asTranslate(req.i18n.t)(keys.pollClosed), 403)\n\t\t}\n\n\t\tlet fields = ((form.fields as FormFieldInstance[] | undefined) ?? []) as FormFieldInstance[]\n\t\tconst incoming = ((data.values as SubmissionValue[] | undefined) ?? []) as SubmissionValue[]\n\t\tconst locale = req.locale ?? 'en'\n\t\tconst t = asFieldTranslate(req.i18n.t)\n\n\t\t// With a resolved choice set for the results field (a poll `optionSource`, or the field type's\n\t\t// own `resolveOptions`) the resolved values are the only accepted answers: options are injected\n\t\t// into the field instance (so the select's membership check and the stored option labels use\n\t\t// them) and membership is also enforced directly, so an empty resolution or a non-select results\n\t\t// field still fails closed. A resolve failure rejects the whole submission rather than skipping\n\t\t// the check. Static authored polls are unaffected: their field validates against its own options\n\t\t// through the normal field pipeline.\n\t\tconst resultsField =\n\t\t\tpollEnabled && typeof poll?.resultsField === 'string' && poll.resultsField.length > 0\n\t\t\t\t? poll.resultsField\n\t\t\t\t: undefined\n\t\tconst resultsInstance = resultsField\n\t\t\t? fields.find((instance) => instance.name === resultsField)\n\t\t\t: undefined\n\t\tconst usesResolver =\n\t\t\t(typeof poll?.optionSource === 'string' && poll.optionSource.length > 0) ||\n\t\t\tBoolean(resultsInstance && registry.get(resultsInstance.blockType)?.resolveOptions)\n\t\tif (resultsField && usesResolver) {\n\t\t\tlet resolved: PollOption[]\n\t\t\ttry {\n\t\t\t\tresolved = await resolveEffectivePollOptions({\n\t\t\t\t\tpayload: req.payload,\n\t\t\t\t\treq,\n\t\t\t\t\tform,\n\t\t\t\t\tsources: pollSourceRegistry ?? new Map(),\n\t\t\t\t\tfieldTypes: registry,\n\t\t\t\t})\n\t\t\t} catch {\n\t\t\t\tthrow new APIError(asTranslate(req.i18n.t)(keys.pollOptionsUnavailable), 503)\n\t\t\t}\n\t\t\tfields = applyPollOptions(fields, resultsField, resolved)\n\t\t\tconst answer = incoming.find((entry) => entry.field === resultsField)?.value\n\t\t\tconst isAnswered = answer != null && answer !== ''\n\t\t\tif (isAnswered && !resolved.some((option) => option.value === answer)) {\n\t\t\t\tthrow new ValidationError(\n\t\t\t\t\t{\n\t\t\t\t\t\tcollection: FORM_SUBMISSIONS_SLUG,\n\t\t\t\t\t\terrors: [\n\t\t\t\t\t\t\t{ path: resultsField, message: asTranslate(req.i18n.t)(keys.validationSelect) },\n\t\t\t\t\t\t],\n\t\t\t\t\t},\n\t\t\t\t\treq.t\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\t\tconst expectedOwner =\n\t\t\ttypeof req.context?.[IDENTITY_CONTEXT_KEY] === 'string'\n\t\t\t\t? (req.context[IDENTITY_CONTEXT_KEY] as string)\n\t\t\t\t: undefined\n\n\t\t// Every consent proof is built from the source as it reads right now, not from anything the\n\t\t// form or the client carries, so a resolver failure rejects the submission rather than\n\t\t// recording an agreement to a statement the server cannot vouch for.\n\t\tlet consentEntries: ConsentSourceEntry[] = []\n\t\tif (consentSources && fields.some((field) => field.blockType === 'consent')) {\n\t\t\ttry {\n\t\t\t\tconsentEntries = await resolveConsentEntries({\n\t\t\t\t\tpayload: req.payload,\n\t\t\t\t\treq,\n\t\t\t\t\tform,\n\t\t\t\t\tsources: consentSources,\n\t\t\t\t})\n\t\t\t} catch {\n\t\t\t\tthrow new APIError(asTranslate(req.i18n.t)(keys.consentSourcesUnavailable), 503)\n\t\t\t}\n\t\t}\n\n\t\t// Calc extension values re-resolve fresh here, never trusting the render-time embedding, and a\n\t\t// resolver failure rejects the submission: a calc total is money math, so an outage must not\n\t\t// silently evaluate to 0 (the render path fails open instead; see `calcAfterRead`). Surfaced as\n\t\t// a translated 503 (the consent-sources precedent above), with the cause logged server-side\n\t\t// rather than leaked to the anonymous caller.\n\t\tlet calcResolved: CalcResolved | undefined\n\t\tif (fields.some((instance) => calcExpressionOf(instance) !== undefined)) {\n\t\t\tlet resolved: CalcResolved\n\t\t\ttry {\n\t\t\t\tresolved = await resolveCalcContext({\n\t\t\t\t\tfields,\n\t\t\t\t\tsources: calcSources ?? {},\n\t\t\t\t\t// Double cast: a host's generated Form interface has no index signature, so the direct\n\t\t\t\t\t// cast fails under a consumer tsconfig even though the shape is a plain document.\n\t\t\t\t\tform: form as unknown as { id: number | string } & Record<string, unknown>,\n\t\t\t\t\tpayload: req.payload,\n\t\t\t\t\treq,\n\t\t\t\t})\n\t\t\t} catch (error) {\n\t\t\t\treq.payload.logger?.error(\n\t\t\t\t\t`@10x-media/form-builder: calc source resolution failed for form ${String(formId)}: ${\n\t\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t\t}`\n\t\t\t\t)\n\t\t\t\tthrow new APIError(asTranslate(req.i18n.t)(keys.calcSourcesUnavailable), 503)\n\t\t\t}\n\t\t\tconst functions =\n\t\t\t\tcalcFunctions && Object.keys(calcFunctions).length > 0\n\t\t\t\t\t? Object.fromEntries(\n\t\t\t\t\t\t\tObject.entries(calcFunctions).map(([name, definition]) => [name, definition.apply])\n\t\t\t\t\t\t)\n\t\t\t\t\t: undefined\n\t\t\tcalcResolved = { ...resolved, ...(functions ? { functions } : {}) }\n\t\t}\n\n\t\tconst result = await runSubmission({\n\t\t\tfields,\n\t\t\tvalues: incoming,\n\t\t\tcalcResolved,\n\t\t\tregistry,\n\t\t\truleRegistry,\n\t\t\tconsentEntries,\n\t\t\tconsentSnapshot,\n\t\t\tlocale,\n\t\t\tt,\n\t\t\toperation: 'create',\n\t\t\treq,\n\t\t\tpayload: req.payload,\n\t\t\tformId: formId as number | string,\n\t\t\tuploadSlug,\n\t\t\texpectedOwner,\n\t\t\tstrictUploadOwnership,\n\t\t})\n\n\t\tif (result.errors.length > 0) {\n\t\t\tthrow new ValidationError({ collection: FORM_SUBMISSIONS_SLUG, errors: result.errors }, req.t)\n\t\t}\n\n\t\tdata.values = result.values\n\t\tdata.descriptors = result.descriptors\n\t\tdata.consent = result.consent.length > 0 ? result.consent : undefined\n\t\tdata.locale = locale\n\t\t// Unauthenticated submits are always 'complete'. A client-supplied 'partial' would\n\t\t// silently skip all post-submit actions and events. Authenticated callers (e.g. an\n\t\t// admin draft-save flow) may set status themselves.\n\t\tif (!req.user) {\n\t\t\tdata.status = 'complete'\n\t\t}\n\t\treturn data\n\t}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;AAqDA,MAAa,sBACX,EACA,UACA,cACA,aACA,eACA,gBACA,iBACA,YACA,oBACA,4BAED,OAAO,EAAE,MAAM,WAAW,aAAa,UAAU;CAGhD,MAAM,eAAe,cAAc,WAAW,mBAAmB,GAAG,IAAI,KAAA;CACxE,IAAK,cAAc,YAAY,iBAAiB,KAAA,KAAc,CAAC,MAC9D,OAAO;CAER,MAAM,SAAS,KAAK;CACpB,IAAI,UAAU,MACb,OAAO;CAER,IAAI,iBAAiB,KAAA,GAAW;EAE/B,MAAM,iBAAkB,aAAgD;EACxE,MAAM,cAAc,kBAAkB,OAAO,SAAS,cAAc,IAAI;EACxE,IAAI,eAAe,QAAQ,OAAO,WAAW,MAAM,OAAO,MAAM,GAC/D,MAAM,IAAI,SAAS,qEAAqE,GAAG;CAE7F;CAEA,MAAM,OAAO,MAAM,IAAI,QAAQ,SAAS;EACvC,YAAY;EACZ,IAAI;EACJ,OAAO;EACP,QAAQ,IAAI;EACZ;CACD,CAAC;CAID,MAAM,OAAO,aAAa,KAAK,IAAI;CACnC,MAAM,cAAc,KAAK,gBAAgB;CACzC,IAAI,QAAQ,oBAAoB;EAC/B;EACA,aAAa,MAAM,gBAAgB;CACpC;CAIA,IAAI,eAAe,aAAa,IAAI,GACnC,MAAM,IAAI,SAAS,YAAY,IAAI,KAAK,CAAC,EAAE,KAAK,UAAU,GAAG,GAAG;CAGjE,IAAI,SAAW,KAAK,UAA8C,CAAC;CACnE,MAAM,WAAa,KAAK,UAA4C,CAAC;CACrE,MAAM,SAAS,IAAI,UAAU;CAC7B,MAAM,IAAI,iBAAiB,IAAI,KAAK,CAAC;CASrC,MAAM,eACL,eAAe,OAAO,MAAM,iBAAiB,YAAY,KAAK,aAAa,SAAS,IACjF,KAAK,eACL,KAAA;CACJ,MAAM,kBAAkB,eACrB,OAAO,MAAM,aAAa,SAAS,SAAS,YAAY,IACxD,KAAA;CACH,MAAM,eACJ,OAAO,MAAM,iBAAiB,YAAY,KAAK,aAAa,SAAS,KACtE,QAAQ,mBAAmB,SAAS,IAAI,gBAAgB,SAAS,GAAG,cAAc;CACnF,IAAI,gBAAgB,cAAc;EACjC,IAAI;EACJ,IAAI;GACH,WAAW,MAAM,4BAA4B;IAC5C,SAAS,IAAI;IACb;IACA;IACA,SAAS,sCAAsB,IAAI,IAAI;IACvC,YAAY;GACb,CAAC;EACF,QAAQ;GACP,MAAM,IAAI,SAAS,YAAY,IAAI,KAAK,CAAC,EAAE,KAAK,sBAAsB,GAAG,GAAG;EAC7E;EACA,SAAS,iBAAiB,QAAQ,cAAc,QAAQ;EACxD,MAAM,SAAS,SAAS,MAAM,UAAU,MAAM,UAAU,YAAY,GAAG;EAEvE,IADmB,UAAU,QAAQ,WAAW,MAC9B,CAAC,SAAS,MAAM,WAAW,OAAO,UAAU,MAAM,GACnE,MAAM,IAAI,gBACT;GACC,YAAY;GACZ,QAAQ,CACP;IAAE,MAAM;IAAc,SAAS,YAAY,IAAI,KAAK,CAAC,EAAE,KAAK,gBAAgB;GAAE,CAC/E;EACD,GACA,IAAI,CACL;CAEF;CACA,MAAM,gBACL,OAAO,IAAI,UAAA,+BAAoC,WAC3C,IAAI,QAAQ,wBACb,KAAA;CAKJ,IAAI,iBAAuC,CAAC;CAC5C,IAAI,kBAAkB,OAAO,MAAM,UAAU,MAAM,cAAc,SAAS,GACzE,IAAI;EACH,iBAAiB,MAAM,sBAAsB;GAC5C,SAAS,IAAI;GACb;GACA;GACA,SAAS;EACV,CAAC;CACF,QAAQ;EACP,MAAM,IAAI,SAAS,YAAY,IAAI,KAAK,CAAC,EAAE,KAAK,yBAAyB,GAAG,GAAG;CAChF;CAQD,IAAI;CACJ,IAAI,OAAO,MAAM,aAAa,iBAAiB,QAAQ,MAAM,KAAA,CAAS,GAAG;EACxE,IAAI;EACJ,IAAI;GACH,WAAW,MAAM,mBAAmB;IACnC;IACA,SAAS,eAAe,CAAC;IAGnB;IACN,SAAS,IAAI;IACb;GACD,CAAC;EACF,SAAS,OAAO;GACf,IAAI,QAAQ,QAAQ,MACnB,mEAAmE,OAAO,MAAM,EAAE,IACjF,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,GAEvD;GACA,MAAM,IAAI,SAAS,YAAY,IAAI,KAAK,CAAC,EAAE,KAAK,sBAAsB,GAAG,GAAG;EAC7E;EACA,MAAM,YACL,iBAAiB,OAAO,KAAK,aAAa,EAAE,SAAS,IAClD,OAAO,YACP,OAAO,QAAQ,aAAa,EAAE,KAAK,CAAC,MAAM,gBAAgB,CAAC,MAAM,WAAW,KAAK,CAAC,CACnF,IACC,KAAA;EACJ,eAAe;GAAE,GAAG;GAAU,GAAI,YAAY,EAAE,UAAU,IAAI,CAAC;EAAG;CACnE;CAEA,MAAM,SAAS,MAAM,cAAc;EAClC;EACA,QAAQ;EACR;EACA;EACA;EACA;EACA;EACA;EACA;EACA,WAAW;EACX;EACA,SAAS,IAAI;EACL;EACR;EACA;EACA;CACD,CAAC;CAED,IAAI,OAAO,OAAO,SAAS,GAC1B,MAAM,IAAI,gBAAgB;EAAE,YAAY;EAAuB,QAAQ,OAAO;CAAO,GAAG,IAAI,CAAC;CAG9F,KAAK,SAAS,OAAO;CACrB,KAAK,cAAc,OAAO;CAC1B,KAAK,UAAU,OAAO,QAAQ,SAAS,IAAI,OAAO,UAAU,KAAA;CAC5D,KAAK,SAAS;CAId,IAAI,CAAC,IAAI,MACR,KAAK,SAAS;CAEf,OAAO;AACR"}
@@ -1,7 +1,7 @@
1
1
  import { keys } from "../translations/keys.js";
2
2
  import { asTranslate } from "../translations/server.js";
3
- import { CONTEXT_KEY } from "../spam/constants.js";
4
3
  import { verifyFormContext } from "../context/formContext.js";
4
+ import { CONTEXT_KEY } from "../spam/constants.js";
5
5
  import { APIError } from "payload";
6
6
  //#region src/submissions/verifyContext.ts
7
7
  /**
@@ -0,0 +1,100 @@
1
+ import { pollConfigOf } from "../form/pollState.js";
2
+ import { formIdOf } from "./formIdOf.js";
3
+ import { VOTE_CHANGE_CONTEXT_KEY, votedSubmissionIdFromCookie } from "./votedCookie.js";
4
+ import { APIError, addDataAndFileToRequest, addLocalesToRequestFromData } from "payload";
5
+ //#region src/submissions/voteChangeEndpoint.ts
6
+ const FORM_SUBMISSIONS_SLUG = "form-submissions";
7
+ const FORMS_SLUG = "forms";
8
+ /** Marks the vote-submit endpoint in the sanitized endpoint list (`custom.formBuilder`). */
9
+ const VOTE_SUBMIT_ENDPOINT_TAG = "vote-submit";
10
+ const isComplete = (doc) => doc.status == null || doc.status === "complete";
11
+ /**
12
+ * The submission a cookie-identified re-vote should update, or null when the request must fall
13
+ * through to a plain create. Guards run cheapest-first: no valid signed cookie for the posted form
14
+ * means no form fetch at all, so the delegation overhead on ordinary submissions is one header
15
+ * parse. A token is honored only when the form is an `allowChange` poll and it names a still
16
+ * existing, complete submission of that same form; anything else (pruned row, cross-form replay,
17
+ * legacy `1` marker, tampering) makes the caller a new voter rather than an error.
18
+ */
19
+ const resolveVoteChangeTarget = async (args) => {
20
+ const { req, formId } = args;
21
+ if (formId == null) return null;
22
+ const submissionId = votedSubmissionIdFromCookie(req.headers?.get("cookie"), formId, req.payload.secret);
23
+ if (submissionId == null) return null;
24
+ const form = await req.payload.findByID({
25
+ collection: FORMS_SLUG,
26
+ id: formId,
27
+ depth: 0,
28
+ overrideAccess: true,
29
+ req
30
+ }).catch(() => null);
31
+ if (form?.pollEnabled !== true || pollConfigOf(form.poll)?.allowChange !== true) return null;
32
+ const submission = await req.payload.findByID({
33
+ collection: FORM_SUBMISSIONS_SLUG,
34
+ id: submissionId,
35
+ depth: 0,
36
+ overrideAccess: true,
37
+ req
38
+ }).catch(() => null);
39
+ if (submission == null) return null;
40
+ const stored = submission;
41
+ if (String(formIdOf(stored.form) ?? "") !== String(formId) || !isComplete(stored)) return null;
42
+ return { submissionId: submission.id };
43
+ };
44
+ /**
45
+ * Custom root `POST /form-submissions` endpoint. Payload matches a collection's custom endpoints
46
+ * ahead of its built-in REST routes, so this handler sees every REST create first: when the posted
47
+ * form is an `allowChange` poll and the voted cookie identifies the caller's submission, it turns
48
+ * the request into an in-place update (create-grade hooks opt in via the context flag); otherwise
49
+ * it delegates to the stock create handler found in the same sanitized endpoint list, keeping the
50
+ * default path semantics Payload's, not a reimplementation. The update runs `overrideAccess`
51
+ * because the collection is deliberately update-closed to every API caller; the signed cookie is
52
+ * the credential here, verified above rather than by collection access.
53
+ */
54
+ const buildVoteSubmitEndpoint = () => {
55
+ const handler = async (req) => {
56
+ await addDataAndFileToRequest(req);
57
+ addLocalesToRequestFromData(req);
58
+ const data = req.data ?? {};
59
+ const target = await resolveVoteChangeTarget({
60
+ req,
61
+ formId: data.form
62
+ });
63
+ if (!target) {
64
+ const endpoints = req.payload.collections[FORM_SUBMISSIONS_SLUG]?.config.endpoints;
65
+ const stockCreate = (Array.isArray(endpoints) ? endpoints : []).find((endpoint) => endpoint.method === "post" && endpoint.path === "/" && endpoint.custom?.formBuilder !== "vote-submit");
66
+ if (!stockCreate) throw new APIError("form-builder: stock create endpoint not found", 500);
67
+ if (req.body) Object.defineProperty(req, "body", {
68
+ configurable: true,
69
+ value: null
70
+ });
71
+ return stockCreate.handler(req);
72
+ }
73
+ req.context[VOTE_CHANGE_CONTEXT_KEY] = target.submissionId;
74
+ const doc = await req.payload.update.bind(req.payload)({
75
+ collection: FORM_SUBMISSIONS_SLUG,
76
+ id: target.submissionId,
77
+ data: {
78
+ form: data.form,
79
+ values: data.values
80
+ },
81
+ depth: 0,
82
+ overrideAccess: true,
83
+ req
84
+ });
85
+ return Response.json({
86
+ doc,
87
+ message: req.t("general:updatedSuccessfully")
88
+ }, { status: 200 });
89
+ };
90
+ return {
91
+ path: "/",
92
+ method: "post",
93
+ handler,
94
+ custom: { formBuilder: VOTE_SUBMIT_ENDPOINT_TAG }
95
+ };
96
+ };
97
+ //#endregion
98
+ export { buildVoteSubmitEndpoint };
99
+
100
+ //# sourceMappingURL=voteChangeEndpoint.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"voteChangeEndpoint.js","names":[],"sources":["../../src/submissions/voteChangeEndpoint.ts"],"sourcesContent":["import {\n\tAPIError,\n\taddDataAndFileToRequest,\n\taddLocalesToRequestFromData,\n\ttype Endpoint,\n\ttype PayloadRequest,\n} from 'payload'\nimport { pollConfigOf } from '../form/pollState'\nimport { formIdOf } from './formIdOf'\nimport { VOTE_CHANGE_CONTEXT_KEY, votedSubmissionIdFromCookie } from './votedCookie'\n\nconst FORM_SUBMISSIONS_SLUG = 'form-submissions'\nconst FORMS_SLUG = 'forms'\n\n/** Marks the vote-submit endpoint in the sanitized endpoint list (`custom.formBuilder`). */\nexport const VOTE_SUBMIT_ENDPOINT_TAG = 'vote-submit'\n\nconst isComplete = (doc: { status?: unknown }): boolean =>\n\tdoc.status == null || doc.status === 'complete'\n\n/**\n * The submission a cookie-identified re-vote should update, or null when the request must fall\n * through to a plain create. Guards run cheapest-first: no valid signed cookie for the posted form\n * means no form fetch at all, so the delegation overhead on ordinary submissions is one header\n * parse. A token is honored only when the form is an `allowChange` poll and it names a still\n * existing, complete submission of that same form; anything else (pruned row, cross-form replay,\n * legacy `1` marker, tampering) makes the caller a new voter rather than an error.\n */\nexport const resolveVoteChangeTarget = async (args: {\n\treq: PayloadRequest\n\tformId: number | string | undefined | null\n}): Promise<{ submissionId: number | string } | null> => {\n\tconst { req, formId } = args\n\tif (formId == null) {\n\t\treturn null\n\t}\n\tconst submissionId = votedSubmissionIdFromCookie(\n\t\treq.headers?.get('cookie'),\n\t\tformId,\n\t\treq.payload.secret\n\t)\n\tif (submissionId == null) {\n\t\treturn null\n\t}\n\tconst form = await req.payload\n\t\t.findByID({ collection: FORMS_SLUG, id: formId, depth: 0, overrideAccess: true, req })\n\t\t.catch(() => null)\n\tif (form?.pollEnabled !== true || pollConfigOf(form.poll)?.allowChange !== true) {\n\t\treturn null\n\t}\n\tconst submission = await req.payload\n\t\t.findByID({\n\t\t\tcollection: FORM_SUBMISSIONS_SLUG,\n\t\t\tid: submissionId,\n\t\t\tdepth: 0,\n\t\t\toverrideAccess: true,\n\t\t\treq,\n\t\t})\n\t\t.catch(() => null)\n\tif (submission == null) {\n\t\treturn null\n\t}\n\tconst stored = submission as { form?: unknown; status?: unknown }\n\tif (String(formIdOf(stored.form) ?? '') !== String(formId) || !isComplete(stored)) {\n\t\treturn null\n\t}\n\treturn { submissionId: submission.id as number | string }\n}\n\n/**\n * Custom root `POST /form-submissions` endpoint. Payload matches a collection's custom endpoints\n * ahead of its built-in REST routes, so this handler sees every REST create first: when the posted\n * form is an `allowChange` poll and the voted cookie identifies the caller's submission, it turns\n * the request into an in-place update (create-grade hooks opt in via the context flag); otherwise\n * it delegates to the stock create handler found in the same sanitized endpoint list, keeping the\n * default path semantics Payload's, not a reimplementation. The update runs `overrideAccess`\n * because the collection is deliberately update-closed to every API caller; the signed cookie is\n * the credential here, verified above rather than by collection access.\n */\nexport const buildVoteSubmitEndpoint = (): Endpoint => {\n\tconst handler = async (req: PayloadRequest): Promise<Response> => {\n\t\tawait addDataAndFileToRequest(req)\n\t\taddLocalesToRequestFromData(req)\n\t\tconst data = (req.data ?? {}) as {\n\t\t\tform?: number | string\n\t\t\tvalues?: unknown\n\t\t}\n\t\tconst target = await resolveVoteChangeTarget({ req, formId: data.form })\n\t\tif (!target) {\n\t\t\tconst endpoints = req.payload.collections[FORM_SUBMISSIONS_SLUG]?.config.endpoints\n\t\t\tconst registered: Endpoint[] = Array.isArray(endpoints) ? endpoints : []\n\t\t\t// Next root-POST match excluding our own tag (never handler identity: a host-wrapped\n\t\t\t// handler would find itself and recurse). First-match mirrors handleEndpoints routing,\n\t\t\t// so another plugin's interceptor still wins exactly as it would without us.\n\t\t\tconst stockCreate = registered.find(\n\t\t\t\t(endpoint) =>\n\t\t\t\t\tendpoint.method === 'post' &&\n\t\t\t\t\tendpoint.path === '/' &&\n\t\t\t\t\tendpoint.custom?.formBuilder !== VOTE_SUBMIT_ENDPOINT_TAG\n\t\t\t)\n\t\t\tif (!stockCreate) {\n\t\t\t\tthrow new APIError('form-builder: stock create endpoint not found', 500)\n\t\t\t}\n\t\t\t// A consumed fetch body stays non-null, so Payload's addDataAndFileToRequest wrapper\n\t\t\t// would re-read it and 500; req.data/req.file are already populated, so hide the spent\n\t\t\t// stream (defineProperty: `body` is a getter-only prototype accessor).\n\t\t\tif (req.body) {\n\t\t\t\tObject.defineProperty(req, 'body', { configurable: true, value: null })\n\t\t\t}\n\t\t\treturn stockCreate.handler(req)\n\t\t}\n\t\treq.context[VOTE_CHANGE_CONTEXT_KEY] = target.submissionId\n\t\t// Slug-agnostic cast (the `createSubmission` idiom): a host's generated types pin the\n\t\t// runtime-registered collection's `form` id flavor and `values` JSON shape, which this\n\t\t// framework-level write cannot know. Bound, because `update` is a prototype method.\n\t\tconst update = req.payload.update.bind(req.payload) as unknown as (options: {\n\t\t\tcollection: string\n\t\t\tid: number | string\n\t\t\tdata: { form?: number | string; values?: unknown }\n\t\t\tdepth?: number\n\t\t\toverrideAccess?: boolean\n\t\t\treq?: PayloadRequest\n\t\t}) => Promise<unknown>\n\t\tconst doc = await update({\n\t\t\tcollection: FORM_SUBMISSIONS_SLUG,\n\t\t\tid: target.submissionId,\n\t\t\tdata: { form: data.form, values: data.values },\n\t\t\tdepth: 0,\n\t\t\toverrideAccess: true,\n\t\t\treq,\n\t\t})\n\t\treturn Response.json({ doc, message: req.t('general:updatedSuccessfully') }, { status: 200 })\n\t}\n\treturn { path: '/', method: 'post', handler, custom: { formBuilder: VOTE_SUBMIT_ENDPOINT_TAG } }\n}\n"],"mappings":";;;;;AAWA,MAAM,wBAAwB;AAC9B,MAAM,aAAa;;AAGnB,MAAa,2BAA2B;AAExC,MAAM,cAAc,QACnB,IAAI,UAAU,QAAQ,IAAI,WAAW;;;;;;;;;AAUtC,MAAa,0BAA0B,OAAO,SAGW;CACxD,MAAM,EAAE,KAAK,WAAW;CACxB,IAAI,UAAU,MACb,OAAO;CAER,MAAM,eAAe,4BACpB,IAAI,SAAS,IAAI,QAAQ,GACzB,QACA,IAAI,QAAQ,MACb;CACA,IAAI,gBAAgB,MACnB,OAAO;CAER,MAAM,OAAO,MAAM,IAAI,QACrB,SAAS;EAAE,YAAY;EAAY,IAAI;EAAQ,OAAO;EAAG,gBAAgB;EAAM;CAAI,CAAC,EACpF,YAAY,IAAI;CAClB,IAAI,MAAM,gBAAgB,QAAQ,aAAa,KAAK,IAAI,GAAG,gBAAgB,MAC1E,OAAO;CAER,MAAM,aAAa,MAAM,IAAI,QAC3B,SAAS;EACT,YAAY;EACZ,IAAI;EACJ,OAAO;EACP,gBAAgB;EAChB;CACD,CAAC,EACA,YAAY,IAAI;CAClB,IAAI,cAAc,MACjB,OAAO;CAER,MAAM,SAAS;CACf,IAAI,OAAO,SAAS,OAAO,IAAI,KAAK,EAAE,MAAM,OAAO,MAAM,KAAK,CAAC,WAAW,MAAM,GAC/E,OAAO;CAER,OAAO,EAAE,cAAc,WAAW,GAAsB;AACzD;;;;;;;;;;;AAYA,MAAa,gCAA0C;CACtD,MAAM,UAAU,OAAO,QAA2C;EACjE,MAAM,wBAAwB,GAAG;EACjC,4BAA4B,GAAG;EAC/B,MAAM,OAAQ,IAAI,QAAQ,CAAC;EAI3B,MAAM,SAAS,MAAM,wBAAwB;GAAE;GAAK,QAAQ,KAAK;EAAK,CAAC;EACvE,IAAI,CAAC,QAAQ;GACZ,MAAM,YAAY,IAAI,QAAQ,YAAY,wBAAwB,OAAO;GAKzE,MAAM,eAJyB,MAAM,QAAQ,SAAS,IAAI,YAAY,CAAC,GAIxC,MAC7B,aACA,SAAS,WAAW,UACpB,SAAS,SAAS,OAClB,SAAS,QAAQ,gBAAA,aACnB;GACA,IAAI,CAAC,aACJ,MAAM,IAAI,SAAS,iDAAiD,GAAG;GAKxE,IAAI,IAAI,MACP,OAAO,eAAe,KAAK,QAAQ;IAAE,cAAc;IAAM,OAAO;GAAK,CAAC;GAEvE,OAAO,YAAY,QAAQ,GAAG;EAC/B;EACA,IAAI,QAAQ,2BAA2B,OAAO;EAY9C,MAAM,MAAM,MARG,IAAI,QAAQ,OAAO,KAAK,IAAI,OAQpB,EAAE;GACxB,YAAY;GACZ,IAAI,OAAO;GACX,MAAM;IAAE,MAAM,KAAK;IAAM,QAAQ,KAAK;GAAO;GAC7C,OAAO;GACP,gBAAgB;GAChB;EACD,CAAC;EACD,OAAO,SAAS,KAAK;GAAE;GAAK,SAAS,IAAI,EAAE,6BAA6B;EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;CAC7F;CACA,OAAO;EAAE,MAAM;EAAK,QAAQ;EAAQ;EAAS,QAAQ,EAAE,aAAa,yBAAyB;CAAE;AAChG"}
@@ -1,3 +1,5 @@
1
+ import { Payload } from "payload";
2
+
1
3
  //#region src/submissions/votedCookie.d.ts
2
4
  /** Name of the httpOnly voted cookie for a form: `fb-voted-{formId}`. */
3
5
  declare const votedCookieName: (formId: number | string) => string;
@@ -1,10 +1,28 @@
1
+ import { signFormContext, verifyFormContext } from "../context/formContext.js";
1
2
  //#region src/submissions/votedCookie.ts
2
3
  /**
3
- * `req.context` key under which `validateSubmission` stashes whether the loaded form is poll-enabled
4
- * (its top-level `pollEnabled` flag) so the voted-cookie `afterChange` hook can skip a second form
4
+ * `req.context` key under which `validateSubmission` stashes the loaded form's poll state
5
+ * (`{ pollEnabled, allowChange }`) so the voted-cookie `afterChange` hook can skip a second form
5
6
  * fetch on the same request.
6
7
  */
7
8
  const POLL_CONTEXT_KEY = "formBuilderPollConfig";
9
+ /**
10
+ * `req.context` key the vote-submit endpoint sets to the id of the submission a cookie-identified
11
+ * re-vote is updating. Its presence is what opts the update operation into the create-grade
12
+ * pipeline: the spam guard, full validation in `validateSubmission`, the dedup rule's
13
+ * self-exclusion, and the voted-cookie refresh all key off it, so an unflagged update (host
14
+ * server code, admin tooling) keeps today's behavior exactly.
15
+ */
16
+ const VOTE_CHANGE_CONTEXT_KEY = "formBuilderVoteChange";
17
+ /** The change-target submission id a flagged request carries, or undefined. */
18
+ const voteChangeTargetOf = (req) => {
19
+ const target = req.context?.[VOTE_CHANGE_CONTEXT_KEY];
20
+ return typeof target === "string" || typeof target === "number" ? target : void 0;
21
+ };
22
+ /** Redeclared to avoid a cycle: `collections/formSubmissions` imports from this module. */
23
+ const FORM_SUBMISSIONS_SLUG = "form-submissions";
24
+ /** One year, matching the voted cookie's `Max-Age` so the token never outlives the cookie by less. */
25
+ const VOTED_COOKIE_MAX_AGE_SECONDS = 31536e3;
8
26
  /** Name of the httpOnly voted cookie for a form: `fb-voted-{formId}`. */
9
27
  const votedCookieName = (formId) => `fb-voted-${formId}`;
10
28
  /**
@@ -15,13 +33,42 @@ const votedCookieName = (formId) => `fb-voted-${formId}`;
15
33
  */
16
34
  const hasVotedCookie = (cookieHeader, formId) => {
17
35
  if (!cookieHeader) return false;
36
+ return votedCookieValue(cookieHeader, formId) != null;
37
+ };
38
+ const votedCookieValue = (cookieHeader, formId) => {
39
+ if (!cookieHeader) return null;
18
40
  const name = votedCookieName(formId);
19
- return cookieHeader.split(";").some((pair) => {
41
+ for (const pair of cookieHeader.split(";")) {
20
42
  const eq = pair.indexOf("=");
21
- return eq !== -1 && pair.slice(0, eq).trim() === name;
22
- });
43
+ if (eq !== -1 && pair.slice(0, eq).trim() === name) return pair.slice(eq + 1).trim();
44
+ }
45
+ return null;
46
+ };
47
+ /**
48
+ * Sign a submission id into the voted cookie's value for an `allowChange` poll. The token (same
49
+ * HMAC format as `signFormContext`) is what lets a repeat submit update that submission in place;
50
+ * signing means only ids the server issued are accepted, so a forged cookie cannot point a
51
+ * re-vote at someone else's submission.
52
+ */
53
+ const signVotedCookieValue = (payload, submissionId) => signFormContext({
54
+ payload,
55
+ relationTo: FORM_SUBMISSIONS_SLUG,
56
+ value: submissionId,
57
+ expiresIn: VOTED_COOKIE_MAX_AGE_SECONDS
58
+ });
59
+ /**
60
+ * The submission id carried by a form's voted cookie, or null when the header has no cookie for
61
+ * the form, the value is the legacy boolean marker (`1`), or the token fails verification
62
+ * (tampered, wrong secret, expired). Null means "treat the caller as a new voter".
63
+ */
64
+ const votedSubmissionIdFromCookie = (cookieHeader, formId, secret) => {
65
+ const value = votedCookieValue(cookieHeader, formId);
66
+ if (value == null || value.length === 0) return null;
67
+ const reference = verifyFormContext(value, secret);
68
+ if (reference == null || reference.relationTo !== FORM_SUBMISSIONS_SLUG) return null;
69
+ return String(reference.value);
23
70
  };
24
71
  //#endregion
25
- export { POLL_CONTEXT_KEY, hasVotedCookie, votedCookieName };
72
+ export { POLL_CONTEXT_KEY, VOTED_COOKIE_MAX_AGE_SECONDS, VOTE_CHANGE_CONTEXT_KEY, hasVotedCookie, signVotedCookieValue, voteChangeTargetOf, votedCookieName, votedSubmissionIdFromCookie };
26
73
 
27
74
  //# sourceMappingURL=votedCookie.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"votedCookie.js","names":[],"sources":["../../src/submissions/votedCookie.ts"],"sourcesContent":["/**\n * `req.context` key under which `validateSubmission` stashes whether the loaded form is poll-enabled\n * (its top-level `pollEnabled` flag) so the voted-cookie `afterChange` hook can skip a second form\n * fetch on the same request.\n */\nexport const POLL_CONTEXT_KEY = 'formBuilderPollConfig'\n\n/** Name of the httpOnly voted cookie for a form: `fb-voted-{formId}`. */\nexport const votedCookieName = (formId: number | string): string => `fb-voted-${formId}`\n\n/**\n * Whether a request's `Cookie` header carries the voted marker for a form. For SSR hosts: read\n * the header (e.g. Next's `(await headers()).get('cookie')`) and pass the result to `<Poll hasVoted>`.\n * The cookie is httpOnly (set server-side when the plugin's `poll.votedCookie` option is on), so\n * this server read is the only way to consume it.\n */\nexport const hasVotedCookie = (\n\tcookieHeader: string | null | undefined,\n\tformId: number | string\n): boolean => {\n\tif (!cookieHeader) {\n\t\treturn false\n\t}\n\tconst name = votedCookieName(formId)\n\treturn cookieHeader.split(';').some((pair) => {\n\t\tconst eq = pair.indexOf('=')\n\t\treturn eq !== -1 && pair.slice(0, eq).trim() === name\n\t})\n}\n"],"mappings":";;;;;;AAKA,MAAa,mBAAmB;;AAGhC,MAAa,mBAAmB,WAAoC,YAAY;;;;;;;AAQhF,MAAa,kBACZ,cACA,WACa;CACb,IAAI,CAAC,cACJ,OAAO;CAER,MAAM,OAAO,gBAAgB,MAAM;CACnC,OAAO,aAAa,MAAM,GAAG,EAAE,MAAM,SAAS;EAC7C,MAAM,KAAK,KAAK,QAAQ,GAAG;EAC3B,OAAO,OAAO,MAAM,KAAK,MAAM,GAAG,EAAE,EAAE,KAAK,MAAM;CAClD,CAAC;AACF"}
1
+ {"version":3,"file":"votedCookie.js","names":[],"sources":["../../src/submissions/votedCookie.ts"],"sourcesContent":["import type { Payload } from 'payload'\nimport { signFormContext, verifyFormContext } from '../context/formContext'\n\n/**\n * `req.context` key under which `validateSubmission` stashes the loaded form's poll state\n * (`{ pollEnabled, allowChange }`) so the voted-cookie `afterChange` hook can skip a second form\n * fetch on the same request.\n */\nexport const POLL_CONTEXT_KEY = 'formBuilderPollConfig'\n\n/** The poll state `validateSubmission` stashes under {@link POLL_CONTEXT_KEY}. */\nexport type PollContextState = { pollEnabled: boolean; allowChange: boolean }\n\n/**\n * `req.context` key the vote-submit endpoint sets to the id of the submission a cookie-identified\n * re-vote is updating. Its presence is what opts the update operation into the create-grade\n * pipeline: the spam guard, full validation in `validateSubmission`, the dedup rule's\n * self-exclusion, and the voted-cookie refresh all key off it, so an unflagged update (host\n * server code, admin tooling) keeps today's behavior exactly.\n */\nexport const VOTE_CHANGE_CONTEXT_KEY = 'formBuilderVoteChange'\n\n/** The change-target submission id a flagged request carries, or undefined. */\nexport const voteChangeTargetOf = (req: {\n\tcontext?: Record<string, unknown>\n}): number | string | undefined => {\n\tconst target = req.context?.[VOTE_CHANGE_CONTEXT_KEY]\n\treturn typeof target === 'string' || typeof target === 'number' ? target : undefined\n}\n\n/** Redeclared to avoid a cycle: `collections/formSubmissions` imports from this module. */\nconst FORM_SUBMISSIONS_SLUG = 'form-submissions'\n\n/** One year, matching the voted cookie's `Max-Age` so the token never outlives the cookie by less. */\nexport const VOTED_COOKIE_MAX_AGE_SECONDS = 31_536_000\n\n/** Name of the httpOnly voted cookie for a form: `fb-voted-{formId}`. */\nexport const votedCookieName = (formId: number | string): string => `fb-voted-${formId}`\n\n/**\n * Whether a request's `Cookie` header carries the voted marker for a form. For SSR hosts: read\n * the header (e.g. Next's `(await headers()).get('cookie')`) and pass the result to `<Poll hasVoted>`.\n * The cookie is httpOnly (set server-side when the plugin's `poll.votedCookie` option is on), so\n * this server read is the only way to consume it.\n */\nexport const hasVotedCookie = (\n\tcookieHeader: string | null | undefined,\n\tformId: number | string\n): boolean => {\n\tif (!cookieHeader) {\n\t\treturn false\n\t}\n\treturn votedCookieValue(cookieHeader, formId) != null\n}\n\nconst votedCookieValue = (\n\tcookieHeader: string | null | undefined,\n\tformId: number | string\n): string | null => {\n\tif (!cookieHeader) {\n\t\treturn null\n\t}\n\tconst name = votedCookieName(formId)\n\tfor (const pair of cookieHeader.split(';')) {\n\t\tconst eq = pair.indexOf('=')\n\t\tif (eq !== -1 && pair.slice(0, eq).trim() === name) {\n\t\t\treturn pair.slice(eq + 1).trim()\n\t\t}\n\t}\n\treturn null\n}\n\n/**\n * Sign a submission id into the voted cookie's value for an `allowChange` poll. The token (same\n * HMAC format as `signFormContext`) is what lets a repeat submit update that submission in place;\n * signing means only ids the server issued are accepted, so a forged cookie cannot point a\n * re-vote at someone else's submission.\n */\nexport const signVotedCookieValue = (payload: Payload, submissionId: number | string): string =>\n\tsignFormContext({\n\t\tpayload,\n\t\trelationTo: FORM_SUBMISSIONS_SLUG,\n\t\tvalue: submissionId,\n\t\texpiresIn: VOTED_COOKIE_MAX_AGE_SECONDS,\n\t})\n\n/**\n * The submission id carried by a form's voted cookie, or null when the header has no cookie for\n * the form, the value is the legacy boolean marker (`1`), or the token fails verification\n * (tampered, wrong secret, expired). Null means \"treat the caller as a new voter\".\n */\nexport const votedSubmissionIdFromCookie = (\n\tcookieHeader: string | null | undefined,\n\tformId: number | string,\n\tsecret: string\n): string | null => {\n\tconst value = votedCookieValue(cookieHeader, formId)\n\tif (value == null || value.length === 0) {\n\t\treturn null\n\t}\n\tconst reference = verifyFormContext(value, secret)\n\tif (reference == null || reference.relationTo !== FORM_SUBMISSIONS_SLUG) {\n\t\treturn null\n\t}\n\treturn String(reference.value)\n}\n"],"mappings":";;;;;;;AAQA,MAAa,mBAAmB;;;;;;;;AAYhC,MAAa,0BAA0B;;AAGvC,MAAa,sBAAsB,QAEA;CAClC,MAAM,SAAS,IAAI,UAAU;CAC7B,OAAO,OAAO,WAAW,YAAY,OAAO,WAAW,WAAW,SAAS,KAAA;AAC5E;;AAGA,MAAM,wBAAwB;;AAG9B,MAAa,+BAA+B;;AAG5C,MAAa,mBAAmB,WAAoC,YAAY;;;;;;;AAQhF,MAAa,kBACZ,cACA,WACa;CACb,IAAI,CAAC,cACJ,OAAO;CAER,OAAO,iBAAiB,cAAc,MAAM,KAAK;AAClD;AAEA,MAAM,oBACL,cACA,WACmB;CACnB,IAAI,CAAC,cACJ,OAAO;CAER,MAAM,OAAO,gBAAgB,MAAM;CACnC,KAAK,MAAM,QAAQ,aAAa,MAAM,GAAG,GAAG;EAC3C,MAAM,KAAK,KAAK,QAAQ,GAAG;EAC3B,IAAI,OAAO,MAAM,KAAK,MAAM,GAAG,EAAE,EAAE,KAAK,MAAM,MAC7C,OAAO,KAAK,MAAM,KAAK,CAAC,EAAE,KAAK;CAEjC;CACA,OAAO;AACR;;;;;;;AAQA,MAAa,wBAAwB,SAAkB,iBACtD,gBAAgB;CACf;CACA,YAAY;CACZ,OAAO;CACP,WAAW;AACZ,CAAC;;;;;;AAOF,MAAa,+BACZ,cACA,QACA,WACmB;CACnB,MAAM,QAAQ,iBAAiB,cAAc,MAAM;CACnD,IAAI,SAAS,QAAQ,MAAM,WAAW,GACrC,OAAO;CAER,MAAM,YAAY,kBAAkB,OAAO,MAAM;CACjD,IAAI,aAAa,QAAQ,UAAU,eAAe,uBACjD,OAAO;CAER,OAAO,OAAO,UAAU,KAAK;AAC9B"}
@@ -31,6 +31,7 @@ const clientRuntimeKeys = [
31
31
  keys.fileUploadMisconfigured,
32
32
  keys.fileHintAccepted,
33
33
  keys.fileHintMaxSize,
34
+ keys.pollChangeVote,
34
35
  keys.pollClosed,
35
36
  keys.pollFinalResult,
36
37
  keys.pollResultsAfterClose,
@@ -38,7 +39,8 @@ const clientRuntimeKeys = [
38
39
  keys.resultsNoResponses,
39
40
  keys.resultsResponses,
40
41
  keys.resultsTruncated,
41
- keys.resultsWinner
42
+ keys.resultsWinner,
43
+ keys.resultsYourVote
42
44
  ];
43
45
  //#endregion
44
46
  export { clientRuntimeKeys };
@@ -1 +1 @@
1
- {"version":3,"file":"clientRuntimeKeys.js","names":[],"sources":["../../src/translations/clientRuntimeKeys.ts"],"sourcesContent":["import type { TranslationKey } from './keys'\nimport { keys } from './keys'\n\n/**\n * The typed keys the visitor-facing runtime (`<Form>`, `<Poll>`, `<FormResults>`, and the field\n * renderers) resolves through `RendererTranslate`. A host bridging its own i18n can assert its mirror\n * covers these in a unit test, the non-drifting form of a hand-written `dist/` diff. Kept in exact\n * lockstep with `src/react/**` by `clientRuntimeKeys.test.ts`.\n *\n * For a complete, drift-proof fallback prefer `makeTranslate(locale)` (or\n * `makeTranslate(bundles[locale] ?? en)`): a shipped bundle covers every key, including the\n * client-side validation messages resolved outside these components, so no key can leak English.\n */\nexport const clientRuntimeKeys: readonly TranslationKey[] = [\n\tkeys.formSubmit,\n\tkeys.formNext,\n\tkeys.formBack,\n\tkeys.formClose,\n\tkeys.formSuccess,\n\tkeys.formSubmitFailed,\n\tkeys.formStepStatus,\n\tkeys.formStepInvalid,\n\tkeys.flowStepFallbackTitle,\n\tkeys.repeaterAddRow,\n\tkeys.repeaterRemoveRow,\n\tkeys.repeaterRow,\n\tkeys.fileUploading,\n\tkeys.fileUploaded,\n\tkeys.fileRemove,\n\tkeys.fileTooLarge,\n\tkeys.fileUploadFailed,\n\tkeys.fileUploadMisconfigured,\n\tkeys.fileHintAccepted,\n\tkeys.fileHintMaxSize,\n\tkeys.pollClosed,\n\tkeys.pollFinalResult,\n\tkeys.pollResultsAfterClose,\n\tkeys.pollResultsError,\n\tkeys.resultsNoResponses,\n\tkeys.resultsResponses,\n\tkeys.resultsTruncated,\n\tkeys.resultsWinner,\n]\n"],"mappings":";;;;;;;;;;;;AAaA,MAAa,oBAA+C;CAC3D,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;AACN"}
1
+ {"version":3,"file":"clientRuntimeKeys.js","names":[],"sources":["../../src/translations/clientRuntimeKeys.ts"],"sourcesContent":["import type { TranslationKey } from './keys'\nimport { keys } from './keys'\n\n/**\n * The typed keys the visitor-facing runtime (`<Form>`, `<Poll>`, `<FormResults>`, and the field\n * renderers) resolves through `RendererTranslate`. A host bridging its own i18n can assert its mirror\n * covers these in a unit test, the non-drifting form of a hand-written `dist/` diff. Kept in exact\n * lockstep with `src/react/**` by `clientRuntimeKeys.test.ts`.\n *\n * For a complete, drift-proof fallback prefer `makeTranslate(locale)` (or\n * `makeTranslate(bundles[locale] ?? en)`): a shipped bundle covers every key, including the\n * client-side validation messages resolved outside these components, so no key can leak English.\n */\nexport const clientRuntimeKeys: readonly TranslationKey[] = [\n\tkeys.formSubmit,\n\tkeys.formNext,\n\tkeys.formBack,\n\tkeys.formClose,\n\tkeys.formSuccess,\n\tkeys.formSubmitFailed,\n\tkeys.formStepStatus,\n\tkeys.formStepInvalid,\n\tkeys.flowStepFallbackTitle,\n\tkeys.repeaterAddRow,\n\tkeys.repeaterRemoveRow,\n\tkeys.repeaterRow,\n\tkeys.fileUploading,\n\tkeys.fileUploaded,\n\tkeys.fileRemove,\n\tkeys.fileTooLarge,\n\tkeys.fileUploadFailed,\n\tkeys.fileUploadMisconfigured,\n\tkeys.fileHintAccepted,\n\tkeys.fileHintMaxSize,\n\tkeys.pollChangeVote,\n\tkeys.pollClosed,\n\tkeys.pollFinalResult,\n\tkeys.pollResultsAfterClose,\n\tkeys.pollResultsError,\n\tkeys.resultsNoResponses,\n\tkeys.resultsResponses,\n\tkeys.resultsTruncated,\n\tkeys.resultsWinner,\n\tkeys.resultsYourVote,\n]\n"],"mappings":";;;;;;;;;;;;AAaA,MAAa,oBAA+C;CAC3D,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;AACN"}
@@ -216,6 +216,9 @@ const de = {
216
216
  [keys.pollVisibilityAfterVote]: "Nach der Abstimmung",
217
217
  [keys.pollVisibilityAfterClose]: "Nach Ende der Umfrage",
218
218
  [keys.pollClosesAt]: "Endet am",
219
+ [keys.pollAllowChange]: "Stimmänderung erlauben",
220
+ [keys.pollAllowChangeDescription]: "Wiederkehrende Teilnehmer aktualisieren ihre bestehende Stimme, statt eine weitere abzugeben. Die Zuordnung erfolgt pro Browser über das Abstimmungs-Cookie.",
221
+ [keys.pollAllowChangeNeedsPersistedSubmissions]: "Stimmänderungen benötigen gespeicherte Einsendungen: Aufbewahrung wieder aktivieren oder Stimmänderung deaktivieren.",
219
222
  [keys.pollClosed]: "Diese Umfrage ist beendet.",
220
223
  [keys.pollResultsAfterClose]: "Die Ergebnisse werden nach Ende der Umfrage angezeigt.",
221
224
  [keys.pollOptionSource]: "Optionsquelle",
@@ -246,6 +249,8 @@ const de = {
246
249
  [keys.pollFinalResult]: "Endergebnis",
247
250
  [keys.pollResultsError]: "Ergebnisse konnten nicht geladen werden.",
248
251
  [keys.resultsWinner]: "Gewinner",
252
+ [keys.resultsYourVote]: "Deine Stimme",
253
+ [keys.pollChangeVote]: "Stimme ändern",
249
254
  [keys.validationFileMissing]: "Datei hochladen",
250
255
  [keys.validationFileMimeType]: "Dateityp nicht erlaubt",
251
256
  [keys.validationFileTooLarge]: "Datei ist zu groß",