@10x-media/form-builder 0.1.0-beta.23 → 0.1.0-beta.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/LICENSE +21 -21
- package/dist/collections/formSubmissions.js +2 -1
- package/dist/collections/formSubmissions.js.map +1 -1
- package/dist/spam/spamGuard.js +1 -1
- package/dist/submissions/validateSubmission.js +2 -1
- package/dist/submissions/validateSubmission.js.map +1 -1
- package/dist/submissions/voteChange.js +21 -0
- package/dist/submissions/voteChange.js.map +1 -0
- package/dist/submissions/voteChangeEndpoint.js +2 -1
- package/dist/submissions/voteChangeEndpoint.js.map +1 -1
- package/dist/submissions/votedCookie.js +2 -14
- package/dist/submissions/votedCookie.js.map +1 -1
- package/dist/validation/builtin/notAlreadySubmitted.js +1 -1
- package/dist/validation/builtin/notAlreadySubmitted.js.map +1 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @10x-media/form-builder
|
|
2
2
|
|
|
3
|
+
## 0.1.0-beta.24
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- The `./react` client entry no longer imports `node:crypto`. The built-in `notAlreadySubmitted` rule read the vote-change key from `votedCookie`, which signs cookies with `node:crypto`, so every host app shipped a browser crypto polyfill (and its `eval`-based `vm` shim) with the form renderer. The key and `voteChangeTargetOf` now live in a crypto-free module; `votedCookie` re-exports both, so existing imports keep working.
|
|
8
|
+
|
|
3
9
|
## 0.1.0-beta.23
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 10x Media GmbH
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 10x Media GmbH
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -7,7 +7,8 @@ import { isLoggedIn } from "../plugin/access.js";
|
|
|
7
7
|
import { FORMS_SLUG } from "./forms.js";
|
|
8
8
|
import { formIdOf } from "../submissions/formIdOf.js";
|
|
9
9
|
import { makeVoteTallyHook } from "../poll/votes/voteTallyHook.js";
|
|
10
|
-
import {
|
|
10
|
+
import { voteChangeTargetOf } from "../submissions/voteChange.js";
|
|
11
|
+
import { POLL_CONTEXT_KEY, VOTED_COOKIE_MAX_AGE_SECONDS, signVotedCookieValue, votedCookieName } from "../submissions/votedCookie.js";
|
|
11
12
|
import { buildSpamGuard } from "../spam/spamGuard.js";
|
|
12
13
|
import { validateSubmission } from "../submissions/validateSubmission.js";
|
|
13
14
|
import { verifyContext } from "../submissions/verifyContext.js";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"formSubmissions.js","names":[],"sources":["../../src/collections/formSubmissions.ts"],"sourcesContent":["import type { CollectionAfterChangeHook, CollectionConfig, Field, PayloadRequest } from 'payload'\nimport type { RichTextBodyOption } from '../actions/body/serializeBody'\nimport { dispatchActions } from '../actions/dispatch'\nimport type { ActionRegistry } from '../actions/registry'\nimport type { ActionInstance } from '../actions/runActions'\nimport type { CalcFunction, CalcSource } from '../calc/registry'\nimport type { ConsentSnapshotMode } from '../consent/captureConsent'\nimport type { ConsentSourcesResolver } from '../consent/types'\nimport { resolveEventSink } from '../events/resolveEventSink'\nimport type { FormEventSink } from '../events/types'\nimport type { FieldTypeRegistry } from '../fields/registry'\nimport { pollConfigOf } from '../form/pollState'\nimport { isLoggedIn } from '../plugin/access'\nimport type { CollectionOverrides } from '../plugin/collectionOverrides'\nimport type { PollOptionSourceRegistry } from '../poll/registry'\nimport { makeVoteTallyHook } from '../poll/votes/voteTallyHook'\nimport { buildSpamGuard } from '../spam/spamGuard'\nimport type { ResolvedSpamConfig } from '../spam/types'\nimport { formIdOf } from '../submissions/formIdOf'\nimport { validateSubmission } from '../submissions/validateSubmission'\nimport { verifyContext } from '../submissions/verifyContext'\nimport { buildVoteSubmitEndpoint } from '../submissions/voteChangeEndpoint'\nimport {\n\tPOLL_CONTEXT_KEY,\n\ttype PollContextState,\n\tsignVotedCookieValue,\n\tVOTED_COOKIE_MAX_AGE_SECONDS,\n\tvoteChangeTargetOf,\n\tvotedCookieName,\n} from '../submissions/votedCookie'\nimport { keys } from '../translations/keys'\nimport { labelForKey } from '../translations/server'\nimport type { ValidationRuleRegistry } from '../validation/registry'\nimport { FORMS_SLUG } from './forms'\n\nexport const FORM_SUBMISSIONS_SLUG = 'form-submissions'\n\ntype BuildSubmissionsCollectionArgs = {\n\tregistry: FieldTypeRegistry\n\truleRegistry: ValidationRuleRegistry\n\t/** Registered calc sources; submission validation re-resolves them fresh (a failure rejects the submission). */\n\tcalcSources?: Record<string, CalcSource>\n\t/** Registered calc functions; threaded into submit-time 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\tactionRegistry?: ActionRegistry\n\tevents?: FormEventSink\n\t/** Whether a job runner is likely present; gates the queued vs bounded-inline dispatch path. */\n\thasRunner?: boolean\n\t/** Host override for the inline dispatch deadline (plugin option `dispatch.deadlineMs`). */\n\tdispatchDeadlineMs?: number\n\t/** Body serialization customization forwarded to the inline action dispatch path. */\n\trichText?: RichTextBodyOption\n\t/** The plugin-configured uploads collection slug; absent when uploads are disabled. */\n\tuploadSlug?: string\n\t/** Resolved spam config; when active, prepends the spam guard before validation. `false` disables it. */\n\tspam?: ResolvedSpamConfig | false\n\t/**\n\t * Opt-in (`poll.votedCookie`): set an httpOnly `fb-voted-{formId}` cookie on poll submission\n\t * creates. An `allowChange` poll sets its signed-id cookie regardless of this flag.\n\t */\n\tvotedCookie?: boolean\n\t/** Registered poll option sources; submission validation resolves allowed values through them. */\n\tpollSourceRegistry?: PollOptionSourceRegistry\n\t/** Resolved `poll.votes` option; `false` skips registering the append-only vote tally hook. */\n\tpollVotes?: false | { overrides?: CollectionOverrides }\n\t/**\n\t * When `true`, reveals the standalone `locale`, `values`, `descriptors`, `consent`, and `meta`\n\t * fields in the admin UI. Default `false`, because the `SubmissionAnswers` UI component already\n\t * represents them fully (locale and meta are folded into its Submission-details section).\n\t */\n\tshowRawFields?: boolean\n\toverrides?: CollectionOverrides\n}\n\n/**\n * On a completed submission create, dispatch the form's post-submit actions and emit `submission.created`.\n * Both are side effects on an already-written row, so the whole body is wrapped: nothing it does can throw\n * past the hook (a failed action or sink must never fail the submission write). Dispatch is itself bounded\n * and non-throwing; the inline fallback is awaited so the bound caps the added latency, while the queued\n * path returns immediately. The form's `actions` are null-guarded for legacy rows created before the field\n * existed.\n */\nconst makeAfterChange =\n\t(args: {\n\t\tactionRegistry: ActionRegistry\n\t\tevents?: FormEventSink\n\t\thasRunner: boolean\n\t\trichText?: RichTextBodyOption\n\t\tdispatchDeadlineMs?: number\n\t}): CollectionAfterChangeHook =>\n\tasync ({ doc, operation, req }) => {\n\t\tif (operation !== 'create' || (doc.status != null && doc.status !== 'complete')) {\n\t\t\treturn doc\n\t\t}\n\t\tconst { payload } = req\n\t\ttry {\n\t\t\tconst formId = formIdOf(doc.form)\n\t\t\tif (formId == null) {\n\t\t\t\treturn doc\n\t\t\t}\n\t\t\tconst form = await payload\n\t\t\t\t.findByID({ collection: FORMS_SLUG, id: formId, depth: 0, overrideAccess: true, req })\n\t\t\t\t.catch(() => null)\n\n\t\t\t// Field access blocks every API writer, so this slug-agnostic override write is the only\n\t\t\t// path that can set the outcome stamps. `withReq` only while the create transaction is\n\t\t\t// still open; a late-settlement write happens after the response and passes none.\n\t\t\tconst stamp = async (\n\t\t\t\tdata: { actionFailed?: boolean; actionUncertain?: boolean },\n\t\t\t\twithReq?: PayloadRequest\n\t\t\t) => {\n\t\t\t\tconst update = payload.update.bind(payload) as unknown as (options: {\n\t\t\t\t\tcollection: string\n\t\t\t\t\tid: number | string\n\t\t\t\t\tdata: { actionFailed?: boolean; actionUncertain?: boolean }\n\t\t\t\t\tdepth?: number\n\t\t\t\t\toverrideAccess?: boolean\n\t\t\t\t\treq?: PayloadRequest\n\t\t\t\t}) => Promise<unknown>\n\t\t\t\tawait update({\n\t\t\t\t\tcollection: FORM_SUBMISSIONS_SLUG,\n\t\t\t\t\tid: doc.id as number | string,\n\t\t\t\t\tdata,\n\t\t\t\t\tdepth: 0,\n\t\t\t\t\toverrideAccess: true,\n\t\t\t\t\t...(withReq ? { req: withReq } : {}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tconst emitCreated = async () => {\n\t\t\t\ttry {\n\t\t\t\t\tawait resolveEventSink(args.events).emit({\n\t\t\t\t\t\ttype: 'submission.created',\n\t\t\t\t\t\tformId: String(formId),\n\t\t\t\t\t\tsubmissionId: String(doc.id),\n\t\t\t\t\t\tat: new Date().toISOString(),\n\t\t\t\t\t})\n\t\t\t\t} catch (error) {\n\t\t\t\t\tpayload.logger?.error(\n\t\t\t\t\t\t`@10x-media/form-builder: submission.created sink threw: ${\n\t\t\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t\t\t}`\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst { essentialFailed } = await dispatchActions({\n\t\t\t\tactions: (form?.actions ?? null) as ActionInstance[] | null,\n\t\t\t\tformId,\n\t\t\t\tsubmissionId: doc.id as number | string,\n\t\t\t\tregistry: args.actionRegistry,\n\t\t\t\tpayload,\n\t\t\t\treq,\n\t\t\t\thasRunner: args.hasRunner,\n\t\t\t\tpersistSubmissions: form?.persistSubmissions as boolean | undefined,\n\t\t\t\trichText: args.richText,\n\t\t\t\tdeadlineMs: args.dispatchDeadlineMs,\n\t\t\t\t// A definite refusal stamps `actionFailed` (the operator's replay filter); a deadline\n\t\t\t\t// breach stamps `actionUncertain` instead, because the work may still land and a\n\t\t\t\t// premature failure stamp would invite a duplicate replay.\n\t\t\t\tonEssentialFailed: ({ timedOut }) =>\n\t\t\t\t\tstamp(timedOut ? { actionUncertain: true } : { actionFailed: true }, req),\n\t\t\t\t// The real outcome of a breached pass, recorded durably once the work settles. The\n\t\t\t\t// dispatcher invokes this AFTER the late closing pass on success, so the stamp only\n\t\t\t\t// clears once the skipped work (rest actions, prune) is done and the created event\n\t\t\t\t// follows it, mirroring the on-time order; a `persistSubmissions: false` prune has\n\t\t\t\t// already deleted the row by then, so the clear tolerates a missing target. A late\n\t\t\t\t// failure converges to the definite-failure stamp.\n\t\t\t\tonEssentialSettled: async ({ ok }) => {\n\t\t\t\t\tif (ok) {\n\t\t\t\t\t\tawait stamp({ actionUncertain: false }).catch(() => {})\n\t\t\t\t\t\tawait emitCreated()\n\t\t\t\t\t} else {\n\t\t\t\t\t\tawait stamp({ actionFailed: true, actionUncertain: false })\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t})\n\t\t\t// A failed-essential submission is kept for recovery but is not a completed signup: no\n\t\t\t// created event, so a sink-driven automation never treats it as one.\n\t\t\tif (essentialFailed) {\n\t\t\t\treturn doc\n\t\t\t}\n\n\t\t\tawait emitCreated()\n\t\t} catch (error) {\n\t\t\tpayload.logger?.error(\n\t\t\t\t`@10x-media/form-builder: afterChange dispatch failed for submission ${String(doc.id)}: ${\n\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t}`\n\t\t\t)\n\t\t}\n\t\treturn doc\n\t}\n\nconst isPollContextState = (value: unknown): value is PollContextState =>\n\tvalue != null &&\n\ttypeof value === 'object' &&\n\ttypeof (value as PollContextState).pollEnabled === 'boolean'\n\n/**\n * Voted-cookie hook: after a submission to a poll-enabled form is created (or updated through the\n * vote-change path), appends an httpOnly `fb-voted-{formId}` cookie via `req.responseHeaders`,\n * Payload's supported channel for hook-set response headers (its own auth strategies use it;\n * `handleEndpoints` merges it into every REST response). The value stays the legacy `1` marker\n * under the `poll.votedCookie` plugin option; an `allowChange` poll instead carries the signed\n * submission id (set regardless of the option, because re-vote identification depends on it) and\n * refreshes it on every change. Reads the poll state `validateSubmission` stashed on\n * `req.context`, falling back to a form fetch when absent. httpOnly keeps the marker readable\n * only server-side (`hasVotedCookie`/`resolveVotedSubmission`); the client `<Poll>` keeps its\n * localStorage guard.\n */\nconst makeVotedCookieHook = (args: { votedCookie: boolean }): CollectionAfterChangeHook => {\n\treturn async ({ doc, operation, req }) => {\n\t\tconst changing = operation === 'update' && voteChangeTargetOf(req) != null\n\t\tif (operation !== 'create' && !changing) {\n\t\t\treturn doc\n\t\t}\n\t\tconst formId = formIdOf(doc.form)\n\t\tif (formId == null) {\n\t\t\treturn doc\n\t\t}\n\t\tconst stashed = req.context?.[POLL_CONTEXT_KEY]\n\t\tlet state = isPollContextState(stashed) ? stashed : undefined\n\t\tif (state === undefined) {\n\t\t\tconst form = await req.payload\n\t\t\t\t.findByID({ collection: FORMS_SLUG, id: formId, depth: 0, overrideAccess: true, req })\n\t\t\t\t.catch(() => null)\n\t\t\tstate = {\n\t\t\t\tpollEnabled: form?.pollEnabled === true,\n\t\t\t\tallowChange: pollConfigOf(form?.poll)?.allowChange === true,\n\t\t\t}\n\t\t}\n\t\tif (!state.pollEnabled || !(args.votedCookie || state.allowChange)) {\n\t\t\treturn doc\n\t\t}\n\t\tconst value = state.allowChange\n\t\t\t? signVotedCookieValue(req.payload, doc.id as number | string)\n\t\t\t: '1'\n\t\t// A signed id is a bearer capability (it authorizes changing that vote), so it follows the\n\t\t// host's Payload auth-cookie transport policy: `Secure` whenever the admin user collection's\n\t\t// `auth.cookies.secure` is on. The legacy `1` marker stays a plain UX flag either way.\n\t\tconst adminUserSlug = req.payload.config.admin?.user\n\t\t// String-indexed cast: a host's generated types key `collections` to its own slugs, which\n\t\t// this framework-level read cannot know.\n\t\tconst collections = req.payload.collections as Record<\n\t\t\tstring,\n\t\t\t{ config: { auth?: { cookies?: { secure?: boolean } } } } | undefined\n\t\t>\n\t\tconst authCookies = adminUserSlug ? collections[adminUserSlug]?.config.auth?.cookies : undefined\n\t\tconst secure = state.allowChange && authCookies?.secure === true ? '; Secure' : ''\n\t\treq.responseHeaders ??= new Headers()\n\t\treq.responseHeaders.append(\n\t\t\t'Set-Cookie',\n\t\t\t`${votedCookieName(formId)}=${value}; Path=/; Max-Age=${VOTED_COOKIE_MAX_AGE_SECONDS}; HttpOnly; SameSite=Lax${secure}`\n\t\t)\n\t\treturn doc\n\t}\n}\n\nexport const buildSubmissionsCollection = ({\n\tregistry,\n\truleRegistry,\n\tcalcSources,\n\tcalcFunctions,\n\tconsentSources,\n\tconsentSnapshot,\n\tactionRegistry = new Map(),\n\tevents,\n\thasRunner = false,\n\tdispatchDeadlineMs,\n\trichText,\n\tuploadSlug,\n\tspam,\n\tvotedCookie = false,\n\tpollSourceRegistry,\n\tpollVotes = false,\n\tshowRawFields = false,\n\toverrides,\n}: BuildSubmissionsCollectionArgs): CollectionConfig => {\n\tconst defaultFields: Field[] = [\n\t\t{ name: 'form', type: 'relationship', relationTo: FORMS_SLUG, required: true },\n\t\t{\n\t\t\tname: 'status',\n\t\t\ttype: 'select',\n\t\t\tdefaultValue: 'complete',\n\t\t\toptions: [\n\t\t\t\t{ label: labelForKey(keys.statusComplete), value: 'complete' },\n\t\t\t\t{ label: labelForKey(keys.statusPartial), value: 'partial' },\n\t\t\t],\n\t\t\t// Not clearable: aggregation counts `status: complete` submissions, so a cleared status\n\t\t\t// would drop the submission out of every poll result with nothing in the UI saying so.\n\t\t\tadmin: { isClearable: false },\n\t\t\t// Defense-in-depth at the REST layer: anonymous clients cannot set status via the API.\n\t\t\t// The validateSubmission hook also forces 'complete' server-side, so this covers both paths.\n\t\t\taccess: { create: isLoggedIn, update: isLoggedIn },\n\t\t},\n\t\t// Stamped by the dispatcher when an essential action failed and the row was kept: the flag is\n\t\t// what lets an operator filter these ownerless rows and clear them once the provider has been\n\t\t// fixed and the addresses replayed. Hidden while unset; never client-writable.\n\t\t{\n\t\t\tname: 'actionFailed',\n\t\t\ttype: 'checkbox',\n\t\t\tindex: true,\n\t\t\tlabel: labelForKey(keys.submissionActionFailedFlag),\n\t\t\taccess: { create: () => false, update: () => false },\n\t\t\tadmin: { readOnly: true, condition: (data) => data?.actionFailed === true },\n\t\t},\n\t\t// Stamped instead of `actionFailed` when the essential pass outlived its deadline: the work\n\t\t// may still complete. Cleared once the breached work settles successfully (the row leaves\n\t\t// both filters), or upgraded to `actionFailed` when it settles as a definite failure.\n\t\t{\n\t\t\tname: 'actionUncertain',\n\t\t\ttype: 'checkbox',\n\t\t\tindex: true,\n\t\t\tlabel: labelForKey(keys.submissionActionUncertainFlag),\n\t\t\taccess: { create: () => false, update: () => false },\n\t\t\tadmin: { readOnly: true, condition: (data) => data?.actionUncertain === true },\n\t\t},\n\t\t// answers UI appears first so it is the dominant view when opening a submission document.\n\t\t{\n\t\t\tname: 'answers',\n\t\t\ttype: 'ui',\n\t\t\tadmin: {\n\t\t\t\tcomponents: { Field: '@10x-media/form-builder/rsc#SubmissionAnswers' },\n\t\t\t},\n\t\t},\n\t\t{ name: 'locale', type: 'text', admin: { hidden: !showRawFields } },\n\t\t{ name: 'values', type: 'json', admin: { hidden: !showRawFields } },\n\t\t{ name: 'descriptors', type: 'json', admin: { hidden: !showRawFields } },\n\t\t{ name: 'consent', type: 'json', admin: { hidden: !showRawFields } },\n\t\t{ name: 'meta', type: 'json', admin: { hidden: !showRawFields } },\n\t\t// The verified reference to the document this form was rendered for (see verifyContext). Only forms\n\t\t// rendered with a signed context carry one, so the group is shown only when present. Readable, so it\n\t\t// is a legible audit record of which document a submission came through.\n\t\t{\n\t\t\tname: 'context',\n\t\t\ttype: 'group',\n\t\t\tlabel: labelForKey(keys.submissionContext),\n\t\t\t// A Payload group always materializes as an object, so gate on a populated `relationTo`\n\t\t\t// (not the group itself) to show it only for submissions made through a signed context.\n\t\t\tadmin: { readOnly: true, condition: (data) => Boolean(data?.context?.relationTo) },\n\t\t\tfields: [\n\t\t\t\t{ name: 'relationTo', type: 'text' },\n\t\t\t\t{ name: 'value', type: 'text' },\n\t\t\t],\n\t\t},\n\t]\n\n\treturn {\n\t\t...(overrides ?? {}),\n\t\tslug: FORM_SUBMISSIONS_SLUG,\n\t\tlabels: {\n\t\t\tsingular: labelForKey(keys.collectionSubmissionSingular),\n\t\t\tplural: labelForKey(keys.collectionSubmissionPlural),\n\t\t\t...(overrides?.labels ?? {}),\n\t\t},\n\t\tadmin: {\n\t\t\tgroup: 'Forms',\n\t\t\tdefaultColumns: ['form', 'status', 'locale', 'createdAt'],\n\t\t\t...(overrides?.admin ?? {}),\n\t\t},\n\t\taccess: {\n\t\t\tcreate: () => true,\n\t\t\tread: isLoggedIn,\n\t\t\tupdate: () => false,\n\t\t\t...(overrides?.access ?? {}),\n\t\t},\n\t\thooks: {\n\t\t\t...(overrides?.hooks ?? {}),\n\t\t\t// Spam guard + validateSubmission must remain first: they enforce the security invariant\n\t\t\t// that anonymous callers cannot bypass post-submit actions or supply a forged status.\n\t\t\t// Consumer beforeValidate hooks are appended after so they run on already-validated data.\n\t\t\tbeforeValidate: [\n\t\t\t\t...(spam ? [buildSpamGuard(spam)] : []),\n\t\t\t\t// Verify + store the signed form context before validation stores the answers, and independently\n\t\t\t\t// of spam so it runs even with spam off.\n\t\t\t\tverifyContext(),\n\t\t\t\tvalidateSubmission({\n\t\t\t\t\tregistry,\n\t\t\t\t\truleRegistry,\n\t\t\t\t\tcalcSources,\n\t\t\t\t\tcalcFunctions,\n\t\t\t\t\tconsentSources,\n\t\t\t\t\tconsentSnapshot,\n\t\t\t\t\tuploadSlug,\n\t\t\t\t\tpollSourceRegistry,\n\t\t\t\t\tstrictUploadOwnership: spam ? spam.uploadOwnership === 'strict' : false,\n\t\t\t\t}),\n\t\t\t\t...(overrides?.hooks?.beforeValidate ?? []),\n\t\t\t],\n\t\t\tafterChange: [\n\t\t\t\t// The tally hook runs first and does not swallow errors: a bump failure must throw so\n\t\t\t\t// Payload rolls the submission create/update back inside the operation transaction,\n\t\t\t\t// rather than being absorbed by the dispatch hook's swallow-all error boundary below.\n\t\t\t\t...(pollVotes !== false ? [makeVoteTallyHook()] : []),\n\t\t\t\tmakeAfterChange({ actionRegistry, events, hasRunner, richText, dispatchDeadlineMs }),\n\t\t\t\t// Always registered: it self-gates on the option and on the form's allowChange flag.\n\t\t\t\tmakeVotedCookieHook({ votedCookie }),\n\t\t\t\t...(overrides?.hooks?.afterChange ?? []),\n\t\t\t],\n\t\t},\n\t\t// The vote-submit endpoint must stay first so it shadows the stock REST create (custom\n\t\t// endpoints match before built-ins); `endpoints: false` from a host disables ours too.\n\t\tendpoints:\n\t\t\toverrides?.endpoints === false\n\t\t\t\t? false\n\t\t\t\t: [buildVoteSubmitEndpoint(), ...(overrides?.endpoints ?? [])],\n\t\tfields: overrides?.fields ? overrides.fields({ defaultFields }) : defaultFields,\n\t}\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"formSubmissions.js","names":[],"sources":["../../src/collections/formSubmissions.ts"],"sourcesContent":["import type { CollectionAfterChangeHook, CollectionConfig, Field, PayloadRequest } from 'payload'\nimport type { RichTextBodyOption } from '../actions/body/serializeBody'\nimport { dispatchActions } from '../actions/dispatch'\nimport type { ActionRegistry } from '../actions/registry'\nimport type { ActionInstance } from '../actions/runActions'\nimport type { CalcFunction, CalcSource } from '../calc/registry'\nimport type { ConsentSnapshotMode } from '../consent/captureConsent'\nimport type { ConsentSourcesResolver } from '../consent/types'\nimport { resolveEventSink } from '../events/resolveEventSink'\nimport type { FormEventSink } from '../events/types'\nimport type { FieldTypeRegistry } from '../fields/registry'\nimport { pollConfigOf } from '../form/pollState'\nimport { isLoggedIn } from '../plugin/access'\nimport type { CollectionOverrides } from '../plugin/collectionOverrides'\nimport type { PollOptionSourceRegistry } from '../poll/registry'\nimport { makeVoteTallyHook } from '../poll/votes/voteTallyHook'\nimport { buildSpamGuard } from '../spam/spamGuard'\nimport type { ResolvedSpamConfig } from '../spam/types'\nimport { formIdOf } from '../submissions/formIdOf'\nimport { validateSubmission } from '../submissions/validateSubmission'\nimport { verifyContext } from '../submissions/verifyContext'\nimport { buildVoteSubmitEndpoint } from '../submissions/voteChangeEndpoint'\nimport {\n\tPOLL_CONTEXT_KEY,\n\ttype PollContextState,\n\tsignVotedCookieValue,\n\tVOTED_COOKIE_MAX_AGE_SECONDS,\n\tvoteChangeTargetOf,\n\tvotedCookieName,\n} from '../submissions/votedCookie'\nimport { keys } from '../translations/keys'\nimport { labelForKey } from '../translations/server'\nimport type { ValidationRuleRegistry } from '../validation/registry'\nimport { FORMS_SLUG } from './forms'\n\nexport const FORM_SUBMISSIONS_SLUG = 'form-submissions'\n\ntype BuildSubmissionsCollectionArgs = {\n\tregistry: FieldTypeRegistry\n\truleRegistry: ValidationRuleRegistry\n\t/** Registered calc sources; submission validation re-resolves them fresh (a failure rejects the submission). */\n\tcalcSources?: Record<string, CalcSource>\n\t/** Registered calc functions; threaded into submit-time 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\tactionRegistry?: ActionRegistry\n\tevents?: FormEventSink\n\t/** Whether a job runner is likely present; gates the queued vs bounded-inline dispatch path. */\n\thasRunner?: boolean\n\t/** Host override for the inline dispatch deadline (plugin option `dispatch.deadlineMs`). */\n\tdispatchDeadlineMs?: number\n\t/** Body serialization customization forwarded to the inline action dispatch path. */\n\trichText?: RichTextBodyOption\n\t/** The plugin-configured uploads collection slug; absent when uploads are disabled. */\n\tuploadSlug?: string\n\t/** Resolved spam config; when active, prepends the spam guard before validation. `false` disables it. */\n\tspam?: ResolvedSpamConfig | false\n\t/**\n\t * Opt-in (`poll.votedCookie`): set an httpOnly `fb-voted-{formId}` cookie on poll submission\n\t * creates. An `allowChange` poll sets its signed-id cookie regardless of this flag.\n\t */\n\tvotedCookie?: boolean\n\t/** Registered poll option sources; submission validation resolves allowed values through them. */\n\tpollSourceRegistry?: PollOptionSourceRegistry\n\t/** Resolved `poll.votes` option; `false` skips registering the append-only vote tally hook. */\n\tpollVotes?: false | { overrides?: CollectionOverrides }\n\t/**\n\t * When `true`, reveals the standalone `locale`, `values`, `descriptors`, `consent`, and `meta`\n\t * fields in the admin UI. Default `false`, because the `SubmissionAnswers` UI component already\n\t * represents them fully (locale and meta are folded into its Submission-details section).\n\t */\n\tshowRawFields?: boolean\n\toverrides?: CollectionOverrides\n}\n\n/**\n * On a completed submission create, dispatch the form's post-submit actions and emit `submission.created`.\n * Both are side effects on an already-written row, so the whole body is wrapped: nothing it does can throw\n * past the hook (a failed action or sink must never fail the submission write). Dispatch is itself bounded\n * and non-throwing; the inline fallback is awaited so the bound caps the added latency, while the queued\n * path returns immediately. The form's `actions` are null-guarded for legacy rows created before the field\n * existed.\n */\nconst makeAfterChange =\n\t(args: {\n\t\tactionRegistry: ActionRegistry\n\t\tevents?: FormEventSink\n\t\thasRunner: boolean\n\t\trichText?: RichTextBodyOption\n\t\tdispatchDeadlineMs?: number\n\t}): CollectionAfterChangeHook =>\n\tasync ({ doc, operation, req }) => {\n\t\tif (operation !== 'create' || (doc.status != null && doc.status !== 'complete')) {\n\t\t\treturn doc\n\t\t}\n\t\tconst { payload } = req\n\t\ttry {\n\t\t\tconst formId = formIdOf(doc.form)\n\t\t\tif (formId == null) {\n\t\t\t\treturn doc\n\t\t\t}\n\t\t\tconst form = await payload\n\t\t\t\t.findByID({ collection: FORMS_SLUG, id: formId, depth: 0, overrideAccess: true, req })\n\t\t\t\t.catch(() => null)\n\n\t\t\t// Field access blocks every API writer, so this slug-agnostic override write is the only\n\t\t\t// path that can set the outcome stamps. `withReq` only while the create transaction is\n\t\t\t// still open; a late-settlement write happens after the response and passes none.\n\t\t\tconst stamp = async (\n\t\t\t\tdata: { actionFailed?: boolean; actionUncertain?: boolean },\n\t\t\t\twithReq?: PayloadRequest\n\t\t\t) => {\n\t\t\t\tconst update = payload.update.bind(payload) as unknown as (options: {\n\t\t\t\t\tcollection: string\n\t\t\t\t\tid: number | string\n\t\t\t\t\tdata: { actionFailed?: boolean; actionUncertain?: boolean }\n\t\t\t\t\tdepth?: number\n\t\t\t\t\toverrideAccess?: boolean\n\t\t\t\t\treq?: PayloadRequest\n\t\t\t\t}) => Promise<unknown>\n\t\t\t\tawait update({\n\t\t\t\t\tcollection: FORM_SUBMISSIONS_SLUG,\n\t\t\t\t\tid: doc.id as number | string,\n\t\t\t\t\tdata,\n\t\t\t\t\tdepth: 0,\n\t\t\t\t\toverrideAccess: true,\n\t\t\t\t\t...(withReq ? { req: withReq } : {}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tconst emitCreated = async () => {\n\t\t\t\ttry {\n\t\t\t\t\tawait resolveEventSink(args.events).emit({\n\t\t\t\t\t\ttype: 'submission.created',\n\t\t\t\t\t\tformId: String(formId),\n\t\t\t\t\t\tsubmissionId: String(doc.id),\n\t\t\t\t\t\tat: new Date().toISOString(),\n\t\t\t\t\t})\n\t\t\t\t} catch (error) {\n\t\t\t\t\tpayload.logger?.error(\n\t\t\t\t\t\t`@10x-media/form-builder: submission.created sink threw: ${\n\t\t\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t\t\t}`\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst { essentialFailed } = await dispatchActions({\n\t\t\t\tactions: (form?.actions ?? null) as ActionInstance[] | null,\n\t\t\t\tformId,\n\t\t\t\tsubmissionId: doc.id as number | string,\n\t\t\t\tregistry: args.actionRegistry,\n\t\t\t\tpayload,\n\t\t\t\treq,\n\t\t\t\thasRunner: args.hasRunner,\n\t\t\t\tpersistSubmissions: form?.persistSubmissions as boolean | undefined,\n\t\t\t\trichText: args.richText,\n\t\t\t\tdeadlineMs: args.dispatchDeadlineMs,\n\t\t\t\t// A definite refusal stamps `actionFailed` (the operator's replay filter); a deadline\n\t\t\t\t// breach stamps `actionUncertain` instead, because the work may still land and a\n\t\t\t\t// premature failure stamp would invite a duplicate replay.\n\t\t\t\tonEssentialFailed: ({ timedOut }) =>\n\t\t\t\t\tstamp(timedOut ? { actionUncertain: true } : { actionFailed: true }, req),\n\t\t\t\t// The real outcome of a breached pass, recorded durably once the work settles. The\n\t\t\t\t// dispatcher invokes this AFTER the late closing pass on success, so the stamp only\n\t\t\t\t// clears once the skipped work (rest actions, prune) is done and the created event\n\t\t\t\t// follows it, mirroring the on-time order; a `persistSubmissions: false` prune has\n\t\t\t\t// already deleted the row by then, so the clear tolerates a missing target. A late\n\t\t\t\t// failure converges to the definite-failure stamp.\n\t\t\t\tonEssentialSettled: async ({ ok }) => {\n\t\t\t\t\tif (ok) {\n\t\t\t\t\t\tawait stamp({ actionUncertain: false }).catch(() => {})\n\t\t\t\t\t\tawait emitCreated()\n\t\t\t\t\t} else {\n\t\t\t\t\t\tawait stamp({ actionFailed: true, actionUncertain: false })\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t})\n\t\t\t// A failed-essential submission is kept for recovery but is not a completed signup: no\n\t\t\t// created event, so a sink-driven automation never treats it as one.\n\t\t\tif (essentialFailed) {\n\t\t\t\treturn doc\n\t\t\t}\n\n\t\t\tawait emitCreated()\n\t\t} catch (error) {\n\t\t\tpayload.logger?.error(\n\t\t\t\t`@10x-media/form-builder: afterChange dispatch failed for submission ${String(doc.id)}: ${\n\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t}`\n\t\t\t)\n\t\t}\n\t\treturn doc\n\t}\n\nconst isPollContextState = (value: unknown): value is PollContextState =>\n\tvalue != null &&\n\ttypeof value === 'object' &&\n\ttypeof (value as PollContextState).pollEnabled === 'boolean'\n\n/**\n * Voted-cookie hook: after a submission to a poll-enabled form is created (or updated through the\n * vote-change path), appends an httpOnly `fb-voted-{formId}` cookie via `req.responseHeaders`,\n * Payload's supported channel for hook-set response headers (its own auth strategies use it;\n * `handleEndpoints` merges it into every REST response). The value stays the legacy `1` marker\n * under the `poll.votedCookie` plugin option; an `allowChange` poll instead carries the signed\n * submission id (set regardless of the option, because re-vote identification depends on it) and\n * refreshes it on every change. Reads the poll state `validateSubmission` stashed on\n * `req.context`, falling back to a form fetch when absent. httpOnly keeps the marker readable\n * only server-side (`hasVotedCookie`/`resolveVotedSubmission`); the client `<Poll>` keeps its\n * localStorage guard.\n */\nconst makeVotedCookieHook = (args: { votedCookie: boolean }): CollectionAfterChangeHook => {\n\treturn async ({ doc, operation, req }) => {\n\t\tconst changing = operation === 'update' && voteChangeTargetOf(req) != null\n\t\tif (operation !== 'create' && !changing) {\n\t\t\treturn doc\n\t\t}\n\t\tconst formId = formIdOf(doc.form)\n\t\tif (formId == null) {\n\t\t\treturn doc\n\t\t}\n\t\tconst stashed = req.context?.[POLL_CONTEXT_KEY]\n\t\tlet state = isPollContextState(stashed) ? stashed : undefined\n\t\tif (state === undefined) {\n\t\t\tconst form = await req.payload\n\t\t\t\t.findByID({ collection: FORMS_SLUG, id: formId, depth: 0, overrideAccess: true, req })\n\t\t\t\t.catch(() => null)\n\t\t\tstate = {\n\t\t\t\tpollEnabled: form?.pollEnabled === true,\n\t\t\t\tallowChange: pollConfigOf(form?.poll)?.allowChange === true,\n\t\t\t}\n\t\t}\n\t\tif (!state.pollEnabled || !(args.votedCookie || state.allowChange)) {\n\t\t\treturn doc\n\t\t}\n\t\tconst value = state.allowChange\n\t\t\t? signVotedCookieValue(req.payload, doc.id as number | string)\n\t\t\t: '1'\n\t\t// A signed id is a bearer capability (it authorizes changing that vote), so it follows the\n\t\t// host's Payload auth-cookie transport policy: `Secure` whenever the admin user collection's\n\t\t// `auth.cookies.secure` is on. The legacy `1` marker stays a plain UX flag either way.\n\t\tconst adminUserSlug = req.payload.config.admin?.user\n\t\t// String-indexed cast: a host's generated types key `collections` to its own slugs, which\n\t\t// this framework-level read cannot know.\n\t\tconst collections = req.payload.collections as Record<\n\t\t\tstring,\n\t\t\t{ config: { auth?: { cookies?: { secure?: boolean } } } } | undefined\n\t\t>\n\t\tconst authCookies = adminUserSlug ? collections[adminUserSlug]?.config.auth?.cookies : undefined\n\t\tconst secure = state.allowChange && authCookies?.secure === true ? '; Secure' : ''\n\t\treq.responseHeaders ??= new Headers()\n\t\treq.responseHeaders.append(\n\t\t\t'Set-Cookie',\n\t\t\t`${votedCookieName(formId)}=${value}; Path=/; Max-Age=${VOTED_COOKIE_MAX_AGE_SECONDS}; HttpOnly; SameSite=Lax${secure}`\n\t\t)\n\t\treturn doc\n\t}\n}\n\nexport const buildSubmissionsCollection = ({\n\tregistry,\n\truleRegistry,\n\tcalcSources,\n\tcalcFunctions,\n\tconsentSources,\n\tconsentSnapshot,\n\tactionRegistry = new Map(),\n\tevents,\n\thasRunner = false,\n\tdispatchDeadlineMs,\n\trichText,\n\tuploadSlug,\n\tspam,\n\tvotedCookie = false,\n\tpollSourceRegistry,\n\tpollVotes = false,\n\tshowRawFields = false,\n\toverrides,\n}: BuildSubmissionsCollectionArgs): CollectionConfig => {\n\tconst defaultFields: Field[] = [\n\t\t{ name: 'form', type: 'relationship', relationTo: FORMS_SLUG, required: true },\n\t\t{\n\t\t\tname: 'status',\n\t\t\ttype: 'select',\n\t\t\tdefaultValue: 'complete',\n\t\t\toptions: [\n\t\t\t\t{ label: labelForKey(keys.statusComplete), value: 'complete' },\n\t\t\t\t{ label: labelForKey(keys.statusPartial), value: 'partial' },\n\t\t\t],\n\t\t\t// Not clearable: aggregation counts `status: complete` submissions, so a cleared status\n\t\t\t// would drop the submission out of every poll result with nothing in the UI saying so.\n\t\t\tadmin: { isClearable: false },\n\t\t\t// Defense-in-depth at the REST layer: anonymous clients cannot set status via the API.\n\t\t\t// The validateSubmission hook also forces 'complete' server-side, so this covers both paths.\n\t\t\taccess: { create: isLoggedIn, update: isLoggedIn },\n\t\t},\n\t\t// Stamped by the dispatcher when an essential action failed and the row was kept: the flag is\n\t\t// what lets an operator filter these ownerless rows and clear them once the provider has been\n\t\t// fixed and the addresses replayed. Hidden while unset; never client-writable.\n\t\t{\n\t\t\tname: 'actionFailed',\n\t\t\ttype: 'checkbox',\n\t\t\tindex: true,\n\t\t\tlabel: labelForKey(keys.submissionActionFailedFlag),\n\t\t\taccess: { create: () => false, update: () => false },\n\t\t\tadmin: { readOnly: true, condition: (data) => data?.actionFailed === true },\n\t\t},\n\t\t// Stamped instead of `actionFailed` when the essential pass outlived its deadline: the work\n\t\t// may still complete. Cleared once the breached work settles successfully (the row leaves\n\t\t// both filters), or upgraded to `actionFailed` when it settles as a definite failure.\n\t\t{\n\t\t\tname: 'actionUncertain',\n\t\t\ttype: 'checkbox',\n\t\t\tindex: true,\n\t\t\tlabel: labelForKey(keys.submissionActionUncertainFlag),\n\t\t\taccess: { create: () => false, update: () => false },\n\t\t\tadmin: { readOnly: true, condition: (data) => data?.actionUncertain === true },\n\t\t},\n\t\t// answers UI appears first so it is the dominant view when opening a submission document.\n\t\t{\n\t\t\tname: 'answers',\n\t\t\ttype: 'ui',\n\t\t\tadmin: {\n\t\t\t\tcomponents: { Field: '@10x-media/form-builder/rsc#SubmissionAnswers' },\n\t\t\t},\n\t\t},\n\t\t{ name: 'locale', type: 'text', admin: { hidden: !showRawFields } },\n\t\t{ name: 'values', type: 'json', admin: { hidden: !showRawFields } },\n\t\t{ name: 'descriptors', type: 'json', admin: { hidden: !showRawFields } },\n\t\t{ name: 'consent', type: 'json', admin: { hidden: !showRawFields } },\n\t\t{ name: 'meta', type: 'json', admin: { hidden: !showRawFields } },\n\t\t// The verified reference to the document this form was rendered for (see verifyContext). Only forms\n\t\t// rendered with a signed context carry one, so the group is shown only when present. Readable, so it\n\t\t// is a legible audit record of which document a submission came through.\n\t\t{\n\t\t\tname: 'context',\n\t\t\ttype: 'group',\n\t\t\tlabel: labelForKey(keys.submissionContext),\n\t\t\t// A Payload group always materializes as an object, so gate on a populated `relationTo`\n\t\t\t// (not the group itself) to show it only for submissions made through a signed context.\n\t\t\tadmin: { readOnly: true, condition: (data) => Boolean(data?.context?.relationTo) },\n\t\t\tfields: [\n\t\t\t\t{ name: 'relationTo', type: 'text' },\n\t\t\t\t{ name: 'value', type: 'text' },\n\t\t\t],\n\t\t},\n\t]\n\n\treturn {\n\t\t...(overrides ?? {}),\n\t\tslug: FORM_SUBMISSIONS_SLUG,\n\t\tlabels: {\n\t\t\tsingular: labelForKey(keys.collectionSubmissionSingular),\n\t\t\tplural: labelForKey(keys.collectionSubmissionPlural),\n\t\t\t...(overrides?.labels ?? {}),\n\t\t},\n\t\tadmin: {\n\t\t\tgroup: 'Forms',\n\t\t\tdefaultColumns: ['form', 'status', 'locale', 'createdAt'],\n\t\t\t...(overrides?.admin ?? {}),\n\t\t},\n\t\taccess: {\n\t\t\tcreate: () => true,\n\t\t\tread: isLoggedIn,\n\t\t\tupdate: () => false,\n\t\t\t...(overrides?.access ?? {}),\n\t\t},\n\t\thooks: {\n\t\t\t...(overrides?.hooks ?? {}),\n\t\t\t// Spam guard + validateSubmission must remain first: they enforce the security invariant\n\t\t\t// that anonymous callers cannot bypass post-submit actions or supply a forged status.\n\t\t\t// Consumer beforeValidate hooks are appended after so they run on already-validated data.\n\t\t\tbeforeValidate: [\n\t\t\t\t...(spam ? [buildSpamGuard(spam)] : []),\n\t\t\t\t// Verify + store the signed form context before validation stores the answers, and independently\n\t\t\t\t// of spam so it runs even with spam off.\n\t\t\t\tverifyContext(),\n\t\t\t\tvalidateSubmission({\n\t\t\t\t\tregistry,\n\t\t\t\t\truleRegistry,\n\t\t\t\t\tcalcSources,\n\t\t\t\t\tcalcFunctions,\n\t\t\t\t\tconsentSources,\n\t\t\t\t\tconsentSnapshot,\n\t\t\t\t\tuploadSlug,\n\t\t\t\t\tpollSourceRegistry,\n\t\t\t\t\tstrictUploadOwnership: spam ? spam.uploadOwnership === 'strict' : false,\n\t\t\t\t}),\n\t\t\t\t...(overrides?.hooks?.beforeValidate ?? []),\n\t\t\t],\n\t\t\tafterChange: [\n\t\t\t\t// The tally hook runs first and does not swallow errors: a bump failure must throw so\n\t\t\t\t// Payload rolls the submission create/update back inside the operation transaction,\n\t\t\t\t// rather than being absorbed by the dispatch hook's swallow-all error boundary below.\n\t\t\t\t...(pollVotes !== false ? [makeVoteTallyHook()] : []),\n\t\t\t\tmakeAfterChange({ actionRegistry, events, hasRunner, richText, dispatchDeadlineMs }),\n\t\t\t\t// Always registered: it self-gates on the option and on the form's allowChange flag.\n\t\t\t\tmakeVotedCookieHook({ votedCookie }),\n\t\t\t\t...(overrides?.hooks?.afterChange ?? []),\n\t\t\t],\n\t\t},\n\t\t// The vote-submit endpoint must stay first so it shadows the stock REST create (custom\n\t\t// endpoints match before built-ins); `endpoints: false` from a host disables ours too.\n\t\tendpoints:\n\t\t\toverrides?.endpoints === false\n\t\t\t\t? false\n\t\t\t\t: [buildVoteSubmitEndpoint(), ...(overrides?.endpoints ?? [])],\n\t\tfields: overrides?.fields ? overrides.fields({ defaultFields }) : defaultFields,\n\t}\n}\n"],"mappings":"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"}
|
package/dist/spam/spamGuard.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { keys } from "../translations/keys.js";
|
|
2
2
|
import { asTranslate } from "../translations/server.js";
|
|
3
|
-
import { voteChangeTargetOf } from "../submissions/
|
|
3
|
+
import { voteChangeTargetOf } from "../submissions/voteChange.js";
|
|
4
4
|
import { firstHop } from "./clientIp.js";
|
|
5
5
|
import { HONEYPOT_VALUE_KEY, IDENTITY_CONTEXT_KEY } from "./constants.js";
|
|
6
6
|
import { extractReservedValues, isHoneypotTripped } from "./reserved.js";
|
|
@@ -7,7 +7,8 @@ import { resolveCalcContext } from "../calc/resolveCalcContext.js";
|
|
|
7
7
|
import { resolveEffectivePollOptions } from "../poll/effectivePollOptions.js";
|
|
8
8
|
import { FORMS_SLUG } from "../collections/forms.js";
|
|
9
9
|
import { formIdOf } from "./formIdOf.js";
|
|
10
|
-
import {
|
|
10
|
+
import { voteChangeTargetOf } from "./voteChange.js";
|
|
11
|
+
import { POLL_CONTEXT_KEY } from "./votedCookie.js";
|
|
11
12
|
import { IDENTITY_CONTEXT_KEY } from "../spam/constants.js";
|
|
12
13
|
import { applyPollOptions } from "../poll/applyPollOptions.js";
|
|
13
14
|
import { runSubmission } from "./runSubmission.js";
|
|
@@ -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 { 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":"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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":"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"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
//#region src/submissions/voteChange.ts
|
|
2
|
+
/**
|
|
3
|
+
* `req.context` key the vote-submit endpoint sets to the id of the submission a cookie-identified
|
|
4
|
+
* re-vote is updating. Its presence is what opts the update operation into the create-grade
|
|
5
|
+
* pipeline: the spam guard, full validation in `validateSubmission`, the dedup rule's
|
|
6
|
+
* self-exclusion, and the voted-cookie refresh all key off it, so an unflagged update (host
|
|
7
|
+
* server code, admin tooling) keeps today's behavior exactly.
|
|
8
|
+
*
|
|
9
|
+
* Kept apart from `votedCookie`, which signs cookies with `node:crypto`: the built-in validation
|
|
10
|
+
* rules read this key and ship in the `./react` client entry.
|
|
11
|
+
*/
|
|
12
|
+
const VOTE_CHANGE_CONTEXT_KEY = "formBuilderVoteChange";
|
|
13
|
+
/** The change-target submission id a flagged request carries, or undefined. */
|
|
14
|
+
const voteChangeTargetOf = (req) => {
|
|
15
|
+
const target = req.context?.[VOTE_CHANGE_CONTEXT_KEY];
|
|
16
|
+
return typeof target === "string" || typeof target === "number" ? target : void 0;
|
|
17
|
+
};
|
|
18
|
+
//#endregion
|
|
19
|
+
export { VOTE_CHANGE_CONTEXT_KEY, voteChangeTargetOf };
|
|
20
|
+
|
|
21
|
+
//# sourceMappingURL=voteChange.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"voteChange.js","names":[],"sources":["../../src/submissions/voteChange.ts"],"sourcesContent":["/**\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 *\n * Kept apart from `votedCookie`, which signs cookies with `node:crypto`: the built-in validation\n * rules read this key and ship in the `./react` client entry.\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"],"mappings":";;;;;;;;;;;AAUA,MAAa,0BAA0B;;AAGvC,MAAa,sBAAsB,QAEA;CAClC,MAAM,SAAS,IAAI,UAAU;CAC7B,OAAO,OAAO,WAAW,YAAY,OAAO,WAAW,WAAW,SAAS,KAAA;AAC5E"}
|
|
@@ -3,7 +3,8 @@ import { asTranslate } from "../translations/server.js";
|
|
|
3
3
|
import "../actions/dispatchContext.js";
|
|
4
4
|
import { pollConfigOf } from "../form/pollState.js";
|
|
5
5
|
import { formIdOf } from "./formIdOf.js";
|
|
6
|
-
import { VOTE_CHANGE_CONTEXT_KEY
|
|
6
|
+
import { VOTE_CHANGE_CONTEXT_KEY } from "./voteChange.js";
|
|
7
|
+
import { votedSubmissionIdFromCookie } from "./votedCookie.js";
|
|
7
8
|
import { APIError, addDataAndFileToRequest, addLocalesToRequestFromData } from "payload";
|
|
8
9
|
//#region src/submissions/voteChangeEndpoint.ts
|
|
9
10
|
const FORM_SUBMISSIONS_SLUG = "form-submissions";
|
|
@@ -1 +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 {\n\tESSENTIAL_ACTION_FAILED_CONTEXT_KEY,\n\tESSENTIAL_ACTION_UNCERTAIN_CONTEXT_KEY,\n} from '../actions/dispatchContext'\nimport { pollConfigOf } from '../form/pollState'\nimport { keys } from '../translations/keys'\nimport { asTranslate } from '../translations/server'\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\tconst response = await stockCreate.handler(req)\n\t\t\t// An essential action's failure is the submission's failure: the row is committed and kept\n\t\t\t// (see dispatchActions), but the visitor must not be told it worked. 502 for a definite\n\t\t\t// refusal by the upstream the submission exists for; 504 when it never answered inside the\n\t\t\t// deadline, because \"we are not sure this completed\" is a different fact from \"this\n\t\t\t// failed\" and prompts waiting over retrying.\n\t\t\tif (response.ok) {\n\t\t\t\tif (req.context?.[ESSENTIAL_ACTION_UNCERTAIN_CONTEXT_KEY] === true) {\n\t\t\t\t\treturn Response.json(\n\t\t\t\t\t\t{ errors: [{ message: asTranslate(req.t)(keys.submissionActionUncertain) }] },\n\t\t\t\t\t\t{ status: 504 }\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tif (req.context?.[ESSENTIAL_ACTION_FAILED_CONTEXT_KEY] === true) {\n\t\t\t\t\treturn Response.json(\n\t\t\t\t\t\t{ errors: [{ message: asTranslate(req.t)(keys.submissionActionFailed) }] },\n\t\t\t\t\t\t{ status: 502 }\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn response\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":"
|
|
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 {\n\tESSENTIAL_ACTION_FAILED_CONTEXT_KEY,\n\tESSENTIAL_ACTION_UNCERTAIN_CONTEXT_KEY,\n} from '../actions/dispatchContext'\nimport { pollConfigOf } from '../form/pollState'\nimport { keys } from '../translations/keys'\nimport { asTranslate } from '../translations/server'\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\tconst response = await stockCreate.handler(req)\n\t\t\t// An essential action's failure is the submission's failure: the row is committed and kept\n\t\t\t// (see dispatchActions), but the visitor must not be told it worked. 502 for a definite\n\t\t\t// refusal by the upstream the submission exists for; 504 when it never answered inside the\n\t\t\t// deadline, because \"we are not sure this completed\" is a different fact from \"this\n\t\t\t// failed\" and prompts waiting over retrying.\n\t\t\tif (response.ok) {\n\t\t\t\tif (req.context?.[ESSENTIAL_ACTION_UNCERTAIN_CONTEXT_KEY] === true) {\n\t\t\t\t\treturn Response.json(\n\t\t\t\t\t\t{ errors: [{ message: asTranslate(req.t)(keys.submissionActionUncertain) }] },\n\t\t\t\t\t\t{ status: 504 }\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tif (req.context?.[ESSENTIAL_ACTION_FAILED_CONTEXT_KEY] === true) {\n\t\t\t\t\treturn Response.json(\n\t\t\t\t\t\t{ errors: [{ message: asTranslate(req.t)(keys.submissionActionFailed) }] },\n\t\t\t\t\t\t{ status: 502 }\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn response\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":";;;;;;;;;AAiBA,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,MAAM,WAAW,MAAM,YAAY,QAAQ,GAAG;GAM9C,IAAI,SAAS,IAAI;IAChB,IAAI,IAAI,UAAA,2CAAsD,MAC7D,OAAO,SAAS,KACf,EAAE,QAAQ,CAAC,EAAE,SAAS,YAAY,IAAI,CAAC,EAAE,KAAK,yBAAyB,EAAE,CAAC,EAAE,GAC5E,EAAE,QAAQ,IAAI,CACf;IAED,IAAI,IAAI,UAAA,wCAAmD,MAC1D,OAAO,SAAS,KACf,EAAE,QAAQ,CAAC,EAAE,SAAS,YAAY,IAAI,CAAC,EAAE,KAAK,sBAAsB,EAAE,CAAC,EAAE,GACzE,EAAE,QAAQ,IAAI,CACf;GAEF;GACA,OAAO;EACR;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,4 +1,5 @@
|
|
|
1
1
|
import { signFormContext, verifyFormContext } from "../context/formContext.js";
|
|
2
|
+
import "./voteChange.js";
|
|
2
3
|
//#region src/submissions/votedCookie.ts
|
|
3
4
|
/**
|
|
4
5
|
* `req.context` key under which `validateSubmission` stashes the loaded form's poll state
|
|
@@ -6,19 +7,6 @@ import { signFormContext, verifyFormContext } from "../context/formContext.js";
|
|
|
6
7
|
* fetch on the same request.
|
|
7
8
|
*/
|
|
8
9
|
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
10
|
/** Redeclared to avoid a cycle: `collections/formSubmissions` imports from this module. */
|
|
23
11
|
const FORM_SUBMISSIONS_SLUG = "form-submissions";
|
|
24
12
|
/** One year, matching the voted cookie's `Max-Age` so the token never outlives the cookie by less. */
|
|
@@ -69,6 +57,6 @@ const votedSubmissionIdFromCookie = (cookieHeader, formId, secret) => {
|
|
|
69
57
|
return String(reference.value);
|
|
70
58
|
};
|
|
71
59
|
//#endregion
|
|
72
|
-
export { POLL_CONTEXT_KEY, VOTED_COOKIE_MAX_AGE_SECONDS,
|
|
60
|
+
export { POLL_CONTEXT_KEY, VOTED_COOKIE_MAX_AGE_SECONDS, hasVotedCookie, signVotedCookieValue, votedCookieName, votedSubmissionIdFromCookie };
|
|
73
61
|
|
|
74
62
|
//# sourceMappingURL=votedCookie.js.map
|
|
@@ -1 +1 @@
|
|
|
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\
|
|
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\nexport { VOTE_CHANGE_CONTEXT_KEY, voteChangeTargetOf } from './voteChange'\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;;AAQhC,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"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { keys } from "../../translations/keys.js";
|
|
2
|
-
import { voteChangeTargetOf } from "../../submissions/
|
|
2
|
+
import { voteChangeTargetOf } from "../../submissions/voteChange.js";
|
|
3
3
|
import { defineValidationRule } from "../defineValidationRule.js";
|
|
4
4
|
//#region src/validation/builtin/notAlreadySubmitted.ts
|
|
5
5
|
const FORM_SUBMISSIONS_SLUG = "form-submissions";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"notAlreadySubmitted.js","names":[],"sources":["../../../src/validation/builtin/notAlreadySubmitted.ts"],"sourcesContent":["import { voteChangeTargetOf } from '../../submissions/
|
|
1
|
+
{"version":3,"file":"notAlreadySubmitted.js","names":[],"sources":["../../../src/validation/builtin/notAlreadySubmitted.ts"],"sourcesContent":["import { voteChangeTargetOf } from '../../submissions/voteChange'\nimport { keys } from '../../translations/keys'\nimport { defineValidationRule } from '../defineValidationRule'\n\nconst FORM_SUBMISSIONS_SLUG = 'form-submissions'\nconst SCAN_LIMIT = 500\n\n/**\n * Server-only async exemplar: rejects a value already present for the same form. Scans up to\n * `SCAN_LIMIT` recent submissions in JS (the values live in a json column, so a portable DB query is\n * not available); a future phase can add an indexed dedup column for scale.\n */\nexport const notAlreadySubmittedRule = defineValidationRule<Record<string, never>, unknown>({\n\ttype: 'notAlreadySubmitted',\n\tlabel: keys.ruleNotAlreadySubmitted,\n\tdescription: keys.ruleNotAlreadySubmittedDescription,\n\tclient: false,\n\tdefaultMessage: keys.ruleNotAlreadySubmittedMessage,\n\tvalidate: async ({ value, field, payload, req, formId, message }) => {\n\t\tif (value == null || value === '' || !payload || formId == null) {\n\t\t\treturn true\n\t\t}\n\t\t// A vote change validates against everyone else's submissions, never its own stored row:\n\t\t// without the exclusion a re-vote keeping the same value would always collide with itself.\n\t\tconst changeTarget = req ? voteChangeTargetOf(req) : undefined\n\t\tconst result = await payload.find({\n\t\t\tcollection: FORM_SUBMISSIONS_SLUG,\n\t\t\twhere:\n\t\t\t\tchangeTarget !== undefined\n\t\t\t\t\t? { and: [{ form: { equals: formId } }, { id: { not_equals: changeTarget } }] }\n\t\t\t\t\t: { form: { equals: formId } },\n\t\t\tlimit: SCAN_LIMIT,\n\t\t\tdepth: 0,\n\t\t\t// Trusted internal dedup read: scan every prior submission of this form regardless of the\n\t\t\t// submitter's own access. This is the local-API default made explicit, matching the sibling\n\t\t\t// resolvePollOutcome/aggregateResponses reads; the `form` filter keeps it single-tenant.\n\t\t\toverrideAccess: true,\n\t\t\treq,\n\t\t})\n\t\tconst clash = result.docs.some((doc) => {\n\t\t\tconst values = (doc as { values?: { field: string; value: unknown }[] }).values ?? []\n\t\t\treturn values.some((entry) => entry.field === field.name && entry.value === value)\n\t\t})\n\t\treturn clash ? message() : true\n\t},\n})\n"],"mappings":";;;;AAIA,MAAM,wBAAwB;AAC9B,MAAM,aAAa;;;;;;AAOnB,MAAa,0BAA0B,qBAAqD;CAC3F,MAAM;CACN,OAAO,KAAK;CACZ,aAAa,KAAK;CAClB,QAAQ;CACR,gBAAgB,KAAK;CACrB,UAAU,OAAO,EAAE,OAAO,OAAO,SAAS,KAAK,QAAQ,cAAc;EACpE,IAAI,SAAS,QAAQ,UAAU,MAAM,CAAC,WAAW,UAAU,MAC1D,OAAO;EAIR,MAAM,eAAe,MAAM,mBAAmB,GAAG,IAAI,KAAA;EAmBrD,QAJc,MAdO,QAAQ,KAAK;GACjC,YAAY;GACZ,OACC,iBAAiB,KAAA,IACd,EAAE,KAAK,CAAC,EAAE,MAAM,EAAE,QAAQ,OAAO,EAAE,GAAG,EAAE,IAAI,EAAE,YAAY,aAAa,EAAE,CAAC,EAAE,IAC5E,EAAE,MAAM,EAAE,QAAQ,OAAO,EAAE;GAC/B,OAAO;GACP,OAAO;GAIP,gBAAgB;GAChB;EACD,CAAC,GACoB,KAAK,MAAM,QAAQ;GAEvC,QADgB,IAAyD,UAAU,CAAC,GACtE,MAAM,UAAU,MAAM,UAAU,MAAM,QAAQ,MAAM,UAAU,KAAK;EAClF,CACW,IAAI,QAAQ,IAAI;CAC5B;AACD,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@10x-media/form-builder",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.24",
|
|
4
4
|
"description": "End-to-end forms platform for Payload: author, validate, render, collect, aggregate, and act.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -99,9 +99,9 @@
|
|
|
99
99
|
"tsdown": "0.22.1",
|
|
100
100
|
"typescript": "5.9.3",
|
|
101
101
|
"vitest": "4.1.7",
|
|
102
|
-
"@10x-media/tsconfig": "0.0.0",
|
|
103
|
-
"@10x-media/tsdown-config": "0.0.0",
|
|
104
102
|
"@10x-media/payload-test-harness": "0.0.0",
|
|
103
|
+
"@10x-media/tsdown-config": "0.0.0",
|
|
104
|
+
"@10x-media/tsconfig": "0.0.0",
|
|
105
105
|
"@10x-media/vitest-config": "0.0.0"
|
|
106
106
|
},
|
|
107
107
|
"publishConfig": {
|