From dba35aac45c4602df699557bd88a7f51a804d281 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Wed, 30 Sep 2026 17:29:57 +0100 Subject: [PATCH 01/12] feat!: add on-demand validation for collections and globals Written with AI --- docs/access-control/collections.mdx | 14 +- docs/access-control/fields.mdx | 12 +- docs/access-control/globals.mdx | 10 +- docs/access-control/overview.mdx | 10 +- docs/configuration/localization.mdx | 4 +- docs/local-api/overview.mdx | 1 + docs/rest-api/overview.mdx | 3 + docs/validation/overview.mdx | 319 ++++++++++++++++++ packages/graphql/src/index.ts | 2 + .../graphql/src/resolvers/collections/find.ts | 4 +- .../src/resolvers/collections/findByID.ts | 10 +- .../resolvers/collections/findVersionByID.ts | 10 +- .../src/resolvers/collections/findVersions.ts | 4 +- .../src/resolvers/collections/validate.ts | 61 ++++ .../graphql/src/resolvers/globals/findOne.ts | 12 +- .../src/resolvers/globals/findVersionByID.ts | 12 +- .../src/resolvers/globals/findVersions.ts | 4 +- .../graphql/src/resolvers/globals/validate.ts | 47 +++ .../src/schema/buildMutationInputType.ts | 7 +- .../graphql/src/schema/buildPoliciesType.ts | 54 +-- .../src/schema/buildValidationResultType.ts | 28 ++ .../graphql/src/schema/initCollections.ts | 26 ++ packages/graphql/src/schema/initGlobals.ts | 24 ++ packages/payload/src/admin/RichText.ts | 6 +- packages/payload/src/auth/getAccessResults.ts | 10 +- .../src/auth/operations/forgotPassword.ts | 3 + packages/payload/src/auth/operations/login.ts | 3 + .../payload/src/auth/operations/logout.ts | 3 + .../payload/src/auth/operations/refresh.ts | 3 + .../src/auth/operations/resetPassword.ts | 3 + .../src/auth/operations/verifyEmail.ts | 3 + packages/payload/src/auth/sessions.ts | 6 + .../strategies/local/resetLoginAttempts.ts | 4 + packages/payload/src/auth/types.ts | 11 + packages/payload/src/auth/withBaseAccess.ts | 8 +- .../src/collections/config/defaults.ts | 2 + .../src/collections/config/sanitize.ts | 2 +- .../payload/src/collections/config/types.ts | 18 +- .../src/collections/endpoints/index.ts | 11 + .../src/collections/endpoints/validate.ts | 78 +++++ .../src/collections/operations/create.ts | 18 +- .../src/collections/operations/delete.ts | 3 + .../src/collections/operations/deleteByID.ts | 3 + .../src/collections/operations/docAccess.ts | 2 +- .../collections/operations/local/validate.ts | 191 +++++++++++ .../collections/operations/restoreVersion.ts | 3 + .../src/collections/operations/update.ts | 3 + .../src/collections/operations/updateByID.ts | 3 + .../operations/utilities/update.ts | 53 ++- .../src/collections/operations/validate.ts | 258 ++++++++++++++ packages/payload/src/config/types.ts | 6 +- .../payload/src/errors/ValidationError.ts | 23 +- .../payload/src/fields/config/sanitize.ts | 2 + packages/payload/src/fields/config/types.ts | 16 +- .../src/fields/hooks/beforeChange/index.ts | 4 +- .../src/fields/hooks/beforeChange/promise.ts | 20 +- .../hooks/beforeChange/traverseFields.ts | 4 +- .../src/fields/hooks/beforeValidate/index.ts | 2 +- .../fields/hooks/beforeValidate/promise.ts | 5 +- .../hooks/beforeValidate/traverseFields.ts | 2 +- .../payload/src/globals/config/sanitize.ts | 6 +- packages/payload/src/globals/config/types.ts | 15 +- .../payload/src/globals/endpoints/index.ts | 6 + .../payload/src/globals/endpoints/validate.ts | 44 +++ .../src/globals/operations/docAccess.ts | 2 +- .../src/globals/operations/local/validate.ts | 119 +++++++ .../src/globals/operations/restoreVersion.ts | 4 + .../payload/src/globals/operations/update.ts | 27 +- .../src/globals/operations/validate.ts | 259 ++++++++++++++ .../hierarchy/hooks/collectionBeforeChange.ts | 2 +- packages/payload/src/index.ts | 49 ++- packages/payload/src/query-presets/access.ts | 8 +- packages/payload/src/query-presets/config.ts | 2 +- packages/payload/src/queues/localAPI.ts | 5 + .../payload/src/queues/utilities/updateJob.ts | 3 + packages/payload/src/types/index.ts | 11 +- packages/payload/src/uploads/uploadFiles.ts | 3 + .../src/utilities/assertNoValidationWrite.ts | 14 + .../src/utilities/createPayloadRequest.ts | 4 +- packages/payload/src/utilities/deepMerge.ts | 32 ++ .../entityInputSchema/filterFieldsByAccess.ts | 2 + .../src/utilities/flattenDataByLocale.ts | 247 ++++++++++++++ .../utilities/getAccessOperationRequest.ts | 21 ++ .../getEntityPermissions.ts | 21 +- .../populateFieldPermissions.ts | 11 +- .../src/utilities/getFieldPermissions.ts | 4 +- .../isValidationErrorPathLocalized.ts | 214 ++++++++++++ .../src/utilities/parseValidationLocale.ts | 79 +++++ .../src/utilities/projectNonLocalizedData.ts | 153 +++++++++ .../src/utilities/resolvePublishAllLocales.ts | 19 ++ .../src/utilities/resolveValidationLocales.ts | 295 ++++++++++++++++ .../utilities/runLocaleScopedValidation.ts | 151 +++++++++ .../src/utilities/toValidationResult.ts | 30 ++ .../drafts/replaceWithDraftIfAvailable.ts | 54 ++- packages/payload/src/versions/saveVersion.ts | 3 + packages/payload/src/versions/schedule/job.ts | 16 +- packages/plugin-multi-tenant/src/types.ts | 2 +- .../src/utilities/addCollectionAccess.ts | 14 +- .../src/features/blocks/server/validate.ts | 2 +- .../src/features/link/server/validate.ts | 2 +- .../src/features/typesServer.ts | 4 +- .../src/features/upload/server/validate.ts | 2 +- .../src/utilities/buildInitialState.ts | 2 +- .../src/elements/DocumentControls/index.tsx | 6 +- .../addFieldStatePromise.ts | 3 +- .../forms/fieldSchemasToFormState/index.tsx | 3 +- .../fieldSchemasToFormState/iterateFields.ts | 3 +- .../ui/src/views/CreateFirstUser/index.tsx | 3 +- 108 files changed, 3277 insertions(+), 177 deletions(-) create mode 100644 docs/validation/overview.mdx create mode 100644 packages/graphql/src/resolvers/collections/validate.ts create mode 100644 packages/graphql/src/resolvers/globals/validate.ts create mode 100644 packages/graphql/src/schema/buildValidationResultType.ts create mode 100644 packages/payload/src/collections/endpoints/validate.ts create mode 100644 packages/payload/src/collections/operations/local/validate.ts create mode 100644 packages/payload/src/collections/operations/validate.ts create mode 100644 packages/payload/src/globals/endpoints/validate.ts create mode 100644 packages/payload/src/globals/operations/local/validate.ts create mode 100644 packages/payload/src/globals/operations/validate.ts create mode 100644 packages/payload/src/utilities/assertNoValidationWrite.ts create mode 100644 packages/payload/src/utilities/flattenDataByLocale.ts create mode 100644 packages/payload/src/utilities/getAccessOperationRequest.ts create mode 100644 packages/payload/src/utilities/isValidationErrorPathLocalized.ts create mode 100644 packages/payload/src/utilities/parseValidationLocale.ts create mode 100644 packages/payload/src/utilities/projectNonLocalizedData.ts create mode 100644 packages/payload/src/utilities/resolvePublishAllLocales.ts create mode 100644 packages/payload/src/utilities/resolveValidationLocales.ts create mode 100644 packages/payload/src/utilities/runLocaleScopedValidation.ts create mode 100644 packages/payload/src/utilities/toValidationResult.ts diff --git a/docs/access-control/collections.mdx b/docs/access-control/collections.mdx index c71d5cdf1ea..354ce96c81a 100644 --- a/docs/access-control/collections.mdx +++ b/docs/access-control/collections.mdx @@ -39,6 +39,7 @@ export const CollectionWithAccessControl: CollectionConfig = { read: () => {...}, update: () => {...}, delete: () => {...}, + validate: () => {...}, // Auth-enabled Collections only admin: () => {...}, @@ -53,12 +54,13 @@ export const CollectionWithAccessControl: CollectionConfig = { The following options are available: -| Function | Allows/Denies Access | -| ------------ | -------------------------------------------------------------------- | -| **`create`** | Used in the `create` operation. [More details](#create). | -| **`read`** | Used in the `find` and `findByID` operations. [More details](#read). | -| **`update`** | Used in the `update` operation. [More details](#update). | -| **`delete`** | Used in the `delete` operation. [More details](#delete). | +| Function | Allows/Denies Access | +| -------------- | ----------------------------------------------------------------------------------------------------------------- | +| **`create`** | Used in the `create` operation. [More details](#create). | +| **`read`** | Used in the `find` and `findByID` operations. [More details](#read). | +| **`update`** | Used in the `update` operation. [More details](#update). | +| **`delete`** | Used in the `delete` operation. [More details](#delete). | +| **`validate`** | Optionally overrides `update` access for [on-demand validation](../validation/overview#access-control-and-hooks). | If a Collection supports [`Authentication`](../authentication/overview), the following additional options are available: diff --git a/docs/access-control/fields.mdx b/docs/access-control/fields.mdx index 649910da33d..26b70abb4cc 100644 --- a/docs/access-control/fields.mdx +++ b/docs/access-control/fields.mdx @@ -48,6 +48,7 @@ export const Posts: CollectionConfig = { create: ({ req: { user } }) => { ... }, read: ({ req: { user } }) => { ... }, update: ({ req: { user } }) => { ... }, + validate: ({ req: { user } }) => { ... }, }, // highlight-end }; @@ -57,11 +58,12 @@ export const Posts: CollectionConfig = { The following options are available: -| Function | Purpose | -| ------------ | ---------------------------------------------------------------------------------------------------------- | -| **`create`** | Allows or denies the ability to set a field's value when creating a new document. [More details](#create). | -| **`read`** | Allows or denies the ability to read a field's value. [More details](#read). | -| **`update`** | Allows or denies the ability to update a field's value [More details](#update). | +| Function | Purpose | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| **`create`** | Allows or denies the ability to set a field's value when creating a new document. [More details](#create). | +| **`read`** | Allows or denies the ability to read a field's value. [More details](#read). | +| **`update`** | Allows or denies the ability to update a field's value. [More details](#update). | +| **`validate`** | Optionally overrides `update` access for candidate field data during [on-demand validation](../validation/overview#access-control-and-hooks). | ### Create diff --git a/docs/access-control/globals.mdx b/docs/access-control/globals.mdx index 07dccd73188..491edb5bf25 100644 --- a/docs/access-control/globals.mdx +++ b/docs/access-control/globals.mdx @@ -37,6 +37,7 @@ const GlobalWithAccessControl: GlobalConfig = { access: { read: ({ req: { user } }) => {...}, update: ({ req: { user } }) => {...}, + validate: ({ req: { user } }) => {...}, // Version-enabled Globals only readVersions: () => {...}, @@ -49,10 +50,11 @@ export default Header The following options are available: -| Function | Allows/Denies Access | -| ------------ | --------------------------------------------------------------- | -| **`read`** | Used in the `findOne` Global operation. [More details](#read). | -| **`update`** | Used in the `update` Global operation. [More details](#update). | +| Function | Allows/Denies Access | +| -------------- | ----------------------------------------------------------------------------------------------------------------- | +| **`read`** | Used in the `findOne` Global operation. [More details](#read). | +| **`update`** | Used in the `update` Global operation. [More details](#update). | +| **`validate`** | Optionally overrides `update` access for [on-demand validation](../validation/overview#access-control-and-hooks). | If a Global supports [Versions](../versions/overview), the following additional options are available: diff --git a/docs/access-control/overview.mdx b/docs/access-control/overview.mdx index dee4a229377..895d6423441 100644 --- a/docs/access-control/overview.mdx +++ b/docs/access-control/overview.mdx @@ -12,6 +12,10 @@ Access Control determines what a user can and cannot do with any given Document, Access Control functions are scoped to the _operation_, meaning you can have different rules for `create`, `read`, `update`, `delete`, etc. Access Control functions are executed _before_ any changes are made and _before_ any operations are completed. This allows you to determine if the user has the necessary permissions before fulfilling the request. +[On-demand validation](../validation/overview#access-control-and-hooks) uses its own first-class +`validate` operation for collection, global, and field access control. Its access policy falls back +to the corresponding `update` function unless `validate` is configured explicitly. + There are many use cases for Access Control, including: - Allowing anyone `read` access to all posts @@ -45,9 +49,9 @@ const defaultPayloadAccess = ({ req: { payload, user } }) => { **Important:** By default, all [Local API](../local-api/overview) operations - respect Access Control based on the passed `user`. Set - `overrideAccess: true` only when the operation should entirely bypass - Access Control. See [Local API Access Control](../local-api/access-control). + respect Access Control based on the passed `user`. Set `overrideAccess: true` + only when the operation should entirely bypass Access Control. See [Local API + Access Control](../local-api/access-control). ## Base Access Control diff --git a/docs/configuration/localization.mdx b/docs/configuration/localization.mdx index edab7dab750..1a9bad07168 100644 --- a/docs/configuration/localization.mdx +++ b/docs/configuration/localization.mdx @@ -10,6 +10,8 @@ Localization is one of the most important features of a modern CMS. It allows yo With Localization, you can begin to serve personalized content to your users based on their specific language preferences, such as a multilingual website or multi-site application. There are no limits to the number of locales you can add to your Payload project. +You can also [validate one or more locales without saving](../validation/overview). + To configure Localization, use the `localization` key in your [Payload Config](./overview): ```ts @@ -95,7 +97,7 @@ The locale codes do not need to be in any specific format. It's up to you to def | Option | Description | | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| **`code`** \* | Unique code to identify the language throughout the APIs for `locale` and `fallbackLocale` | +| **`code`** \* | Unique code to identify the language throughout the APIs for `locale` and `fallbackLocale`. | | **`label`** | A string to use for the selector when choosing a language, or an object keyed on the i18n keys for different languages in use. | | **`rtl`** | A boolean that when true will make the admin UI display in Right-To-Left. | | **`fallbackLocale`** | The code for this language to fallback to when properties of a document are not present. This can be a single locale or array of locales. | diff --git a/docs/local-api/overview.mdx b/docs/local-api/overview.mdx index 1fbfe75a13c..7ddc96a5914 100644 --- a/docs/local-api/overview.mdx +++ b/docs/local-api/overview.mdx @@ -21,6 +21,7 @@ Here are some common examples of how you can use the Local API: - Seeding data via Node seed scripts that you write and maintain - Opening custom Next.js route handlers which feature additional functionality but still rely on Payload - Within [Access Control](../access-control/overview) and [Hooks](../hooks/overview) +- [Validating document candidates without saving them](../validation/overview#local-api) ## Accessing Payload diff --git a/docs/rest-api/overview.mdx b/docs/rest-api/overview.mdx index 91b43d69142..8ffd1eb6e50 100644 --- a/docs/rest-api/overview.mdx +++ b/docs/rest-api/overview.mdx @@ -14,6 +14,9 @@ keywords: rest, api, documentation, Content Management System, cms, headless, ja The REST API is a fully functional HTTP client that allows you to interact with your Documents in a RESTful manner. It supports all CRUD operations and is equipped with automatic pagination, depth, and sorting. All Payload API routes are mounted and prefixed to your config's `routes.api` URL segment (default: `/api`). +To check collection or global candidates without saving them, use the +[on-demand validation endpoints](../validation/overview#rest-api). + For example, if you have a Collection called `pages`, you can fetch its documents directly from the browser or any HTTP client: ```ts diff --git a/docs/validation/overview.mdx b/docs/validation/overview.mdx new file mode 100644 index 00000000000..3c3cc669cee --- /dev/null +++ b/docs/validation/overview.mdx @@ -0,0 +1,319 @@ +--- +title: On-demand Validation +label: Overview +order: 10 +desc: Validate collection and global document candidates without saving them. +keywords: validation, local api, rest api, localization, publishing, access control, hooks, drafts +--- + +On-demand validation checks a collection or global document candidate without saving the candidate, +creating a version, or writing files. Use it for workflow readiness checks, multi-locale review, and +validation before a custom publish flow. + +Field validation failures are returned as a result: + +```ts +import type { ValidationFieldError } from 'payload' + +type ValidationResult = { + valid: boolean + errors: ValidationFieldError[] +} +``` + +Every error has a `path` and `message`. Errors from localized validation passes also set `locale`; +non-localized validation may omit it. Optional field label and table metadata may be present. + +Access denials, missing documents, invalid arguments, and non-validation errors thrown by hooks still +throw normally. + +## Local API + +Use `payload.validate()` for collections and `payload.validateGlobal()` for globals. A collection +call without `id` validates a create candidate and requires `data`. For collection by-ID and global +validation, the stored main or published document is the base by default. Set `draft: true` to use +the newest available draft version instead, falling back to the main document when no draft exists. +Optional, partial `data` is merged over that base. + +```ts +// Validate collection create data in one locale +const createResult = await payload.validate({ + collection: 'posts', + data: { + title: 'Launch announcement', + }, + locale: 'en', +}) +``` + +```ts +// Merge the same partial candidate into each selected locale +const updateResult = await payload.validate({ + collection: 'posts', + id: postID, + data: { + title: 'Ready to publish', + }, + locale: ['en', 'de'], +}) +``` + +```ts +// Validate every locale available to this request +const allLocalesResult = await payload.validate({ + collection: 'posts', + id: postID, + draft: true, + locale: 'all', +}) +``` + +```ts +// Validate a partial global candidate +const globalResult = await payload.validateGlobal({ + slug: 'site-settings', + data: { + announcement: 'Maintenance starts at 22:00', + }, + locale: ['en', 'es'], +}) +``` + +For projects without localization, pass `locale: null` to the Local API or use `?locale=all` with +the REST API. + +Use `context` to add values to `req.context` for the validation lifecycle. You can also reuse a +partial request with `req`. + +### Workflow readiness + +Validation failures do not throw a `ValidationError`. Check `valid` and use the locale, path, and +message on each error: + +```ts +const result = await payload.validate({ + collection: 'posts', + id: postID, + locale: 'all', + overrideAccess: false, + user, +}) + +if (!result.valid) { + return { + ready: false, + issues: result.errors.map(({ locale, message, path }) => ({ + field: path, + locale, + message, + })), + } +} + +return { ready: true } +``` + +The Local API enforces access control by default. Set `overrideAccess: true` only for trusted +internal calls that must skip access control. + +## REST API + +Validation endpoints use `POST`. Send candidate data as a JSON object and select locales with the +required `locale` query parameter. These examples use the default `/api` base path; custom +`routes.api` configuration changes that prefix. + +```ts +// Collection create candidate +const result = await fetch('/api/posts/validate?locale=en', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: 'Launch announcement', + }), +}).then((response) => response.json()) +``` + +```ts +// Collection update candidate; repeated locale parameters select multiple locales +const result = await fetch( + `/api/posts/${postID}/validate?locale=en&locale=de`, + { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: 'Ready to publish', + }), + }, +).then((response) => response.json()) +``` + +```ts +// Global candidate +const result = await fetch('/api/globals/site-settings/validate?locale=en', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + announcement: 'Maintenance starts at 22:00', + }), +}).then((response) => response.json()) +``` + +Use `?locale=all` to validate every available locale: + +```ts +const result = await fetch(`/api/posts/${postID}/validate?locale=all`, { + method: 'POST', + credentials: 'include', +}).then((response) => response.json()) +``` + +The collection create endpoint requires an object body. The collection by-ID and global endpoints +allow the body to be omitted. Those endpoints use the newest available draft as their base, +falling back to the main document when no draft exists. + +The body is always candidate document data. Authentication, access context, and other operation +controls come from the HTTP request, not body properties. + +| Outcome | Status | +| ------------------------------------------ | ------ | +| Valid or invalid field data | `200` | +| Missing, unknown, or unavailable locale | `400` | +| Missing or malformed required request data | `400` | +| Validation access denied | `403` | +| Collection document not found | `404` | + +Other errors use Payload's normal REST error handling. REST validation always enforces access +control. + +## GraphQL API + +Every collection and global gets a `validate` mutation, matching the `create`/`update` +mutations already generated for it. Omit `id` to validate create candidate data; supply `id` to +validate a stored document, with the same draft-fallback behavior as the Local and REST APIs. + +```graphql +mutation { + validatePost(data: { title: "Launch announcement" }) { + valid + errors { + path + message + locale + } + } +} +``` + +Unlike the Local and REST APIs, the GraphQL mutations only validate one locale — the request's +resolved locale, or the `locale` argument when the collection or global has localized fields. There +is no GraphQL equivalent of `locale: 'all'` or a locale array; validate every locale individually, or +use the Local or REST API for that. GraphQL validation always enforces access control. + +## Locale semantics + +`locale` accepts one locale, a non-empty array, or `'all'`. Duplicate locales are removed. `'all'` +uses the locales returned by `localization.filterAvailableLocales`, when configured. Errors from +localized validation passes are tagged with the locale that failed; non-localized validation may +omit `locale`. + +A public multi-locale Local or REST call has one `data` payload. Payload independently merges that +same candidate into every selected locale, then aggregates the results. If a custom interface has +unsaved localized values for only its active locale, do not send those values as a flat +multi-locale candidate. + +Publish-all operations do not automatically run on-demand validation for every locale. When an +application needs this check before publishing, call the Local or REST validation API with +`locale: 'all'` first. Automatic validation of every locale during publish-all will be added in a +follow-up change. + +Fallback locale values are disabled during on-demand validation. Missing data must pass validation +in the locale being checked. + +## Access control and hooks + +Validation falls back to the corresponding `update` access function for collections, globals, and +fields. The fallback still receives `req.operation === 'validate'`. Configure `validate` only when +validation should use a different policy: + +```ts +import type { CollectionConfig, GlobalConfig } from 'payload' + +export const Posts: CollectionConfig = { + slug: 'posts', + access: { + update: ({ req }) => Boolean(req.user), + }, + fields: [ + { + name: 'internalNotes', + type: 'textarea', + access: { + update: ({ req }) => Boolean(req.user), + }, + }, + ], +} + +export const SiteSettings: GlobalConfig = { + slug: 'site-settings', + access: { + update: ({ req }) => req.user?.collection === 'admins', + // Optional: let other authenticated users validate without granting updates. + validate: ({ req }) => Boolean(req.user), + }, + fields: [], +} +``` + +Whether configured directly or inherited from `update`, collection, global, and field access +functions all receive the first-class +`req.operation === 'validate'`. Field `beforeValidate` and `beforeChange` hooks, field validators, +and collection/global `beforeValidate` and `beforeChange` hooks also receive +`operation: 'validate'`. Payload never simulates `'create'` or `'update'` as the access operation. + +When `validate` access returns a `where` constraint and a document exists but doesn't match it, +validation throws `Forbidden` rather than treating the document as absent. Without this, candidate +data could validate against an empty base as though the restricted document didn't exist yet. +Collections already apply this through their existing `update`-by-ID access checks; globals apply +the same rule for validation specifically, even though a global's `find` and `update` stay silent +about a document being restricted rather than absent. + +A collection create-candidate validation has no stored document against which Payload can evaluate +a `where` constraint. Its `validate` access must therefore return a boolean. Payload throws +`Forbidden` if it returns a `where` constraint for a validation call without an `id`. + +For draft-enabled globals, Payload identifies the newest draft before it applies a `where` access +constraint. If that exact draft does not satisfy the constraint, validation throws `Forbidden` +instead of selecting an older draft that does satisfy it. Payload uses the main global only when no +draft is available. + +The validation lifecycle itself does not persist the candidate. Payload also rejects document, +global, upload, and version writes that reuse the active validation request. Hooks still run, +however, and can cause side effects through other Payload APIs, an external service, a raw database +client, or a separate Payload request. Branch side-effecting hooks when needed: + +```ts +import type { CollectionBeforeChangeHook } from 'payload' + +const beforeChange: CollectionBeforeChangeHook = async ({ + data, + operation, +}) => { + if (operation === 'validate') { + return data + } + + await notifySearchIndexer(data) + return data +} +``` + +## Current scope + +On-demand validation is available through the Local, REST, and GraphQL APIs. GraphQL only +validates one locale per call; multi-locale and `'all'` validation are Local and REST API only. It +does not add a standalone "validate all locales" Admin UI action, bulk Admin validation, or +fallback-locale validation. diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts index 3dec8ebd7d0..e340aad78b1 100644 --- a/packages/graphql/src/index.ts +++ b/packages/graphql/src/index.ts @@ -12,6 +12,7 @@ import { accessResolver } from './resolvers/auth/access.js' import { buildFallbackLocaleInputType } from './schema/buildFallbackLocaleInputType.js' import { buildLocaleInputType } from './schema/buildLocaleInputType.js' import { buildPoliciesType } from './schema/buildPoliciesType.js' +import { buildValidationResultType } from './schema/buildValidationResultType.js' import { initCollections } from './schema/initCollections.js' import { initGlobals } from './schema/initGlobals.js' import { wrapCustomFields } from './utilities/wrapCustomResolver.js' @@ -49,6 +50,7 @@ export function configToSchema(config: SanitizedConfig): { blockTypes: {}, groupTypes: {}, tabTypes: {}, + validationResultType: buildValidationResultType(), }, } diff --git a/packages/graphql/src/resolvers/collections/find.ts b/packages/graphql/src/resolvers/collections/find.ts index 527c885a0c8..56c3ee69118 100644 --- a/packages/graphql/src/resolvers/collections/find.ts +++ b/packages/graphql/src/resolvers/collections/find.ts @@ -34,7 +34,9 @@ export function findResolver(collection: Collection): Resolver { 'fallbackLocale', 'transactionID', ])) - const select = (context.select = args.select ? buildSelectForCollectionMany(info, context) : undefined) + const select = (context.select = args.select + ? buildSelectForCollectionMany(info, context) + : undefined) req.locale = args.locale || req.locale req.fallbackLocale = args.fallbackLocale || req.fallbackLocale diff --git a/packages/graphql/src/resolvers/collections/findByID.ts b/packages/graphql/src/resolvers/collections/findByID.ts index 27029a4a4c0..7056d7f2ee9 100644 --- a/packages/graphql/src/resolvers/collections/findByID.ts +++ b/packages/graphql/src/resolvers/collections/findByID.ts @@ -25,8 +25,14 @@ export function findByIDResolver( collection: Collection, ): Resolver> { return async function resolver(_, args, context, info) { - const req = context.req = isolateObjectProperty(context.req, ['locale', 'fallbackLocale', 'transactionID']) - const select = context.select = args.select ? buildSelectForCollection(info, context) : undefined + const req = (context.req = isolateObjectProperty(context.req, [ + 'locale', + 'fallbackLocale', + 'transactionID', + ])) + const select = (context.select = args.select + ? buildSelectForCollection(info, context) + : undefined) req.locale = args.locale || req.locale req.fallbackLocale = args.fallbackLocale || req.fallbackLocale diff --git a/packages/graphql/src/resolvers/collections/findVersionByID.ts b/packages/graphql/src/resolvers/collections/findVersionByID.ts index 268173b2d9c..41923f45f97 100644 --- a/packages/graphql/src/resolvers/collections/findVersionByID.ts +++ b/packages/graphql/src/resolvers/collections/findVersionByID.ts @@ -22,8 +22,14 @@ export type Resolver = ( export function findVersionByIDResolver(collection: Collection): Resolver { return async function resolver(_, args, context, info) { - const req = context.req = isolateObjectProperty(context.req, ['locale', 'fallbackLocale', 'transactionID']) - const select = context.select = args.select ? buildSelectForCollection(info, context) : undefined + const req = (context.req = isolateObjectProperty(context.req, [ + 'locale', + 'fallbackLocale', + 'transactionID', + ])) + const select = (context.select = args.select + ? buildSelectForCollection(info, context) + : undefined) req.locale = args.locale || req.locale req.fallbackLocale = args.fallbackLocale || req.fallbackLocale diff --git a/packages/graphql/src/resolvers/collections/findVersions.ts b/packages/graphql/src/resolvers/collections/findVersions.ts index c2ea037d404..012092f26d3 100644 --- a/packages/graphql/src/resolvers/collections/findVersions.ts +++ b/packages/graphql/src/resolvers/collections/findVersions.ts @@ -32,7 +32,9 @@ export function findVersionsResolver(collection: Collection): Resolver { 'fallbackLocale', 'transactionID', ])) - const select = (context.select = args.select ? buildSelectForCollectionMany(info, context) : undefined) + const select = (context.select = args.select + ? buildSelectForCollectionMany(info, context) + : undefined) req.locale = args.locale || req.locale req.fallbackLocale = args.fallbackLocale || req.fallbackLocale diff --git a/packages/graphql/src/resolvers/collections/validate.ts b/packages/graphql/src/resolvers/collections/validate.ts new file mode 100644 index 00000000000..8ee5317d5de --- /dev/null +++ b/packages/graphql/src/resolvers/collections/validate.ts @@ -0,0 +1,61 @@ +import type { + Collection, + CollectionSlug, + PayloadRequest, + RequiredDataFromCollectionSlug, + ValidationResult, +} from 'payload' +import type { DeepPartial } from 'ts-essentials' + +import { isolateObjectProperty } from 'payload' + +import type { Context } from '../types.js' + +export type Resolver = ( + _: unknown, + args: { + data?: DeepPartial> + draft?: boolean + id?: number | string + locale?: string + }, + context: { + req: PayloadRequest + }, +) => Promise + +/** + * Validates a single locale — the request's resolved locale, or the `locale` argument when + * provided. Unlike the Local and REST APIs, this does not support validating multiple locales or + * `locale: 'all'` in one call. + */ +export function validateResolver( + collection: Collection, +): Resolver { + return async function resolver(_, args, context: Context) { + const { req } = context + const collectionSlug = collection.config.slug as TSlug + const locale = req.payload.config.localization ? args.locale || req.locale : null + + if (args.id === undefined) { + return req.payload.validate({ + collection: collectionSlug, + data: args.data ?? {}, + draft: args.draft, + locale, + overrideAccess: false, + req: isolateObjectProperty(req, 'transactionID'), + }) + } + + return req.payload.validate({ + id: args.id, + collection: collectionSlug, + data: args.data, + draft: args.draft, + locale, + overrideAccess: false, + req: isolateObjectProperty(req, 'transactionID'), + }) + } +} diff --git a/packages/graphql/src/resolvers/globals/findOne.ts b/packages/graphql/src/resolvers/globals/findOne.ts index a1b5bc55dff..f15b594ba3a 100644 --- a/packages/graphql/src/resolvers/globals/findOne.ts +++ b/packages/graphql/src/resolvers/globals/findOne.ts @@ -17,13 +17,19 @@ export type Resolver = ( select?: boolean }, context: Context, - info: GraphQLResolveInfo + info: GraphQLResolveInfo, ) => Promise export function findOne(globalConfig: SanitizedGlobalConfig): Resolver { return async function resolver(_, args, context, info) { - const req = context.req = isolateObjectProperty(context.req, ['locale', 'fallbackLocale', 'transactionID']) - const select = context.select = args.select ? buildSelectForCollection(info, context) : undefined + const req = (context.req = isolateObjectProperty(context.req, [ + 'locale', + 'fallbackLocale', + 'transactionID', + ])) + const select = (context.select = args.select + ? buildSelectForCollection(info, context) + : undefined) const { slug } = globalConfig req.locale = args.locale || req.locale diff --git a/packages/graphql/src/resolvers/globals/findVersionByID.ts b/packages/graphql/src/resolvers/globals/findVersionByID.ts index fda50b7ef28..4891497f00a 100644 --- a/packages/graphql/src/resolvers/globals/findVersionByID.ts +++ b/packages/graphql/src/resolvers/globals/findVersionByID.ts @@ -17,13 +17,19 @@ export type Resolver = ( select?: boolean }, context: Context, - info: GraphQLResolveInfo + info: GraphQLResolveInfo, ) => Promise export function findVersionByID(globalConfig: SanitizedGlobalConfig): Resolver { return async function resolver(_, args, context, info) { - const req = context.req = isolateObjectProperty(context.req, ['locale', 'fallbackLocale', 'transactionID']) - const select = context.select = args.select ? buildSelectForCollection(info, context) : undefined + const req = (context.req = isolateObjectProperty(context.req, [ + 'locale', + 'fallbackLocale', + 'transactionID', + ])) + const select = (context.select = args.select + ? buildSelectForCollection(info, context) + : undefined) req.locale = args.locale || req.locale req.fallbackLocale = args.fallbackLocale || req.fallbackLocale diff --git a/packages/graphql/src/resolvers/globals/findVersions.ts b/packages/graphql/src/resolvers/globals/findVersions.ts index e743c5f9f1a..7edca121465 100644 --- a/packages/graphql/src/resolvers/globals/findVersions.ts +++ b/packages/graphql/src/resolvers/globals/findVersions.ts @@ -30,7 +30,9 @@ export function findVersions(globalConfig: SanitizedGlobalConfig): Resolver { 'fallbackLocale', 'transactionID', ])) - const select = (context.select = args.select ? buildSelectForCollectionMany(info, context) : undefined) + const select = (context.select = args.select + ? buildSelectForCollectionMany(info, context) + : undefined) req.locale = args.locale || req.locale req.fallbackLocale = args.fallbackLocale || req.fallbackLocale diff --git a/packages/graphql/src/resolvers/globals/validate.ts b/packages/graphql/src/resolvers/globals/validate.ts new file mode 100644 index 00000000000..0c3167dc25a --- /dev/null +++ b/packages/graphql/src/resolvers/globals/validate.ts @@ -0,0 +1,47 @@ +import type { + DataFromGlobalSlug, + GlobalSlug, + PayloadRequest, + SanitizedGlobalConfig, + ValidationResult, +} from 'payload' +import type { DeepPartial } from 'ts-essentials' + +import { isolateObjectProperty } from 'payload' + +import type { Context } from '../types.js' + +export type Resolver = ( + _: unknown, + args: { + data?: DeepPartial, 'id'>> + draft?: boolean + locale?: string + }, + context: { + req: PayloadRequest + }, +) => Promise + +/** + * Validates a single locale — the request's resolved locale, or the `locale` argument when + * provided. Unlike the Local and REST APIs, this does not support validating multiple locales or + * `locale: 'all'` in one call. + */ +export function validateResolver( + globalConfig: SanitizedGlobalConfig, +): Resolver { + return async function resolver(_, args, context: Context) { + const { req } = context + const locale = req.payload.config.localization ? args.locale || req.locale : null + + return req.payload.validateGlobal({ + slug: globalConfig.slug as TSlug, + data: args.data, + draft: args.draft, + locale, + overrideAccess: false, + req: isolateObjectProperty(req, 'transactionID'), + }) + } +} diff --git a/packages/graphql/src/schema/buildMutationInputType.ts b/packages/graphql/src/schema/buildMutationInputType.ts index 67fea71e1ed..57de022bac9 100644 --- a/packages/graphql/src/schema/buildMutationInputType.ts +++ b/packages/graphql/src/schema/buildMutationInputType.ts @@ -102,6 +102,7 @@ export function buildMutationInputType({ name: fullName, config, fields: field.fields, + forceNullable, graphqlResult, parentIsLocalized: parentIsLocalized || field.localized, parentName: fullName, @@ -159,6 +160,7 @@ export function buildMutationInputType({ name: fullName, config, fields: field.fields, + forceNullable, graphqlResult, parentIsLocalized: parentIsLocalized || field.localized, parentName: fullName, @@ -168,7 +170,7 @@ export function buildMutationInputType({ return inputObjectTypeConfig } - if (requiresAtLeastOneField) { + if (requiresAtLeastOneField && !forceNullable) { type = new GraphQLNonNull(type) } return { @@ -321,6 +323,7 @@ export function buildMutationInputType({ name: fullName, config, fields: tab.fields, + forceNullable, graphqlResult, parentIsLocalized: parentIsLocalized || tab.localized, parentName: fullName, @@ -330,7 +333,7 @@ export function buildMutationInputType({ return acc } - if (requiresAtLeastOneField) { + if (requiresAtLeastOneField && !forceNullable) { type = new GraphQLNonNull(type) } return { diff --git a/packages/graphql/src/schema/buildPoliciesType.ts b/packages/graphql/src/schema/buildPoliciesType.ts index a94a5191675..12fe43a8d88 100644 --- a/packages/graphql/src/schema/buildPoliciesType.ts +++ b/packages/graphql/src/schema/buildPoliciesType.ts @@ -13,7 +13,14 @@ import { toWords } from 'payload' import { GraphQLJSONObject } from '../packages/graphql-type-json/index.js' import { formatName } from '../utilities/formatName.js' -type OperationType = 'create' | 'delete' | 'read' | 'readVersions' | 'unlock' | 'update' +type OperationType = + | 'create' + | 'delete' + | 'read' + | 'readVersions' + | 'unlock' + | 'update' + | 'validate' type AccessScopes = 'docAccess' | undefined @@ -28,26 +35,29 @@ const buildFields = (label, fieldsToBuild) => if (field.name) { const fieldName = formatName(field.name) - const objectTypeFields: ObjectTypeFields = ['create', 'read', 'update', 'delete'].reduce( - (operations, operation) => { - const capitalizedOperation = operation.charAt(0).toUpperCase() + operation.slice(1) - - return { - ...operations, - [operation]: { - type: new GraphQLObjectType({ - name: `${label}_${fieldName}_${capitalizedOperation}`, - fields: { - permission: { - type: new GraphQLNonNull(GraphQLBoolean), - }, + const objectTypeFields: ObjectTypeFields = [ + 'create', + 'read', + 'update', + 'delete', + 'validate', + ].reduce((operations, operation) => { + const capitalizedOperation = operation.charAt(0).toUpperCase() + operation.slice(1) + + return { + ...operations, + [operation]: { + type: new GraphQLObjectType({ + name: `${label}_${fieldName}_${capitalizedOperation}`, + fields: { + permission: { + type: new GraphQLNonNull(GraphQLBoolean), }, - }), - }, - } - }, - {}, - ) + }, + }), + }, + } + }, {}) if (field.fields) { objectTypeFields.fields = { @@ -165,7 +175,7 @@ export function buildPolicyType(args: BuildPolicyType): GraphQLObjectType { } if (type === 'collection') { - operations = ['create', 'read', 'update', 'delete'] + operations = ['create', 'read', 'update', 'delete', 'validate'] if ( entity.auth && @@ -194,7 +204,7 @@ export function buildPolicyType(args: BuildPolicyType): GraphQLObjectType { } // else create global type - operations = ['read', 'update'] + operations = ['read', 'update', 'validate'] if (entity.versions) { operations.push('readVersions') diff --git a/packages/graphql/src/schema/buildValidationResultType.ts b/packages/graphql/src/schema/buildValidationResultType.ts new file mode 100644 index 00000000000..5479959dec7 --- /dev/null +++ b/packages/graphql/src/schema/buildValidationResultType.ts @@ -0,0 +1,28 @@ +import { + GraphQLBoolean, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLString, +} from 'graphql' + +const validationFieldErrorType = new GraphQLObjectType({ + name: 'PayloadValidationFieldError', + fields: { + locale: { type: GraphQLString }, + message: { type: new GraphQLNonNull(GraphQLString) }, + path: { type: new GraphQLNonNull(GraphQLString) }, + }, +}) + +export const buildValidationResultType = (): GraphQLObjectType => { + return new GraphQLObjectType({ + name: 'PayloadValidationResult', + fields: { + errors: { + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(validationFieldErrorType))), + }, + valid: { type: new GraphQLNonNull(GraphQLBoolean) }, + }, + }) +} diff --git a/packages/graphql/src/schema/initCollections.ts b/packages/graphql/src/schema/initCollections.ts index 0364d8544b5..82059de9388 100644 --- a/packages/graphql/src/schema/initCollections.ts +++ b/packages/graphql/src/schema/initCollections.ts @@ -38,6 +38,7 @@ import { findVersionByIDResolver } from '../resolvers/collections/findVersionByI import { findVersionsResolver } from '../resolvers/collections/findVersions.js' import { restoreVersionResolver } from '../resolvers/collections/restoreVersion.js' import { updateResolver } from '../resolvers/collections/update.js' +import { validateResolver } from '../resolvers/collections/validate.js' import { formatName } from '../utilities/formatName.js' import { buildMutationInputType, getCollectionIDType } from './buildMutationInputType.js' import { buildObjectType } from './buildObjectType.js' @@ -192,6 +193,16 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ collection.graphQL.updateMutationInputType = new GraphQLNonNull(updateMutationInputType) } + const validationMutationInputType = buildMutationInputType({ + name: `${singularName}Validation`, + config, + fields: mutationCreateInputFields, + forceNullable: true, + graphqlResult, + parentIsLocalized: false, + parentName: `${singularName}Validation`, + }) + const queriesEnabled = typeof collectionConfig.graphQL !== 'object' || !collectionConfig.graphQL.disableQueries const mutationsEnabled = @@ -306,6 +317,21 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ resolve: updateResolver(collection), } + graphqlResult.Mutation.fields[`validate${singularName}`] = { + type: graphqlResult.types.validationResultType, + args: { + id: { type: idType }, + ...(validationMutationInputType ? { data: { type: validationMutationInputType } } : {}), + draft: { type: GraphQLBoolean }, + ...(config.localization + ? { + locale: { type: graphqlResult.types.localeInputType }, + } + : {}), + }, + resolve: validateResolver(collection), + } + graphqlResult.Mutation.fields[`delete${singularName}`] = { type: collection.graphQL.type, args: { diff --git a/packages/graphql/src/schema/initGlobals.ts b/packages/graphql/src/schema/initGlobals.ts index eae3a5cd31d..cba5093a748 100644 --- a/packages/graphql/src/schema/initGlobals.ts +++ b/packages/graphql/src/schema/initGlobals.ts @@ -13,6 +13,7 @@ import { findVersionByID } from '../resolvers/globals/findVersionByID.js' import { findVersions } from '../resolvers/globals/findVersions.js' import { restoreVersion } from '../resolvers/globals/restoreVersion.js' import { update } from '../resolvers/globals/update.js' +import { validateResolver as validateGlobalResolver } from '../resolvers/globals/validate.js' import { formatName } from '../utilities/formatName.js' import { buildMutationInputType } from './buildMutationInputType.js' import { buildObjectType } from './buildObjectType.js' @@ -49,6 +50,15 @@ export function initGlobals({ config, graphqlResult }: InitGlobalsGraphQLArgs): parentIsLocalized: false, parentName: formattedName, }) + const validationMutationInputType = buildMutationInputType({ + name: `${formattedName}Validation`, + config, + fields, + forceNullable: true, + graphqlResult, + parentIsLocalized: false, + parentName: `${formattedName}Validation`, + }) graphqlResult.globals.graphQL[slug] = { type: buildObjectType({ name: formattedName, @@ -109,6 +119,20 @@ export function initGlobals({ config, graphqlResult }: InitGlobalsGraphQLArgs): }, resolve: update(global), } + + graphqlResult.Mutation.fields[`validate${formattedName}`] = { + type: graphqlResult.types.validationResultType, + args: { + ...(validationMutationInputType ? { data: { type: validationMutationInputType } } : {}), + draft: { type: GraphQLBoolean }, + ...(config.localization + ? { + locale: { type: graphqlResult.types.localeInputType }, + } + : {}), + }, + resolve: validateGlobalResolver(global), + } } if (global.versions) { diff --git a/packages/payload/src/admin/RichText.ts b/packages/payload/src/admin/RichText.ts index 923fda1cf48..97e0f7c1bd5 100644 --- a/packages/payload/src/admin/RichText.ts +++ b/packages/payload/src/admin/RichText.ts @@ -13,7 +13,7 @@ import type { } from '../fields/config/types.js' import type { SanitizedGlobalConfig } from '../globals/config/types.js' import type { RequestContext, TypedFallbackLocale } from '../index.js' -import type { JsonObject, PayloadRequest, PopulateType } from '../types/index.js' +import type { FieldOperation, JsonObject, PayloadRequest, PopulateType } from '../types/index.js' import type { FieldsToJSONSchemaArgs } from '../utilities/configToJSONSchema.js' import type { RichTextFieldClientProps, RichTextFieldServerProps } from './fields/RichText.js' import type { FieldDiffClientProps, FieldDiffServerProps, FieldSchemaMap } from './types.js' @@ -77,7 +77,7 @@ export type BeforeValidateRichTextHookArgs< TSiblingData = any, > = { /** A string relating to which operation the field type is currently executing within. */ - operation: 'create' | 'update' + operation: 'create' | 'update' | 'validate' overrideAccess?: boolean /** The sibling data of the document before changes being applied. */ previousSiblingDoc?: TSiblingData @@ -108,7 +108,7 @@ export type BeforeChangeRichTextHookArgs< /** Only available in `beforeChange` field hooks */ mergeLocaleActions?: (() => Promise | void)[] /** A string relating to which operation the field type is currently executing within. */ - operation?: 'create' | 'delete' | 'read' | 'update' + operation?: FieldOperation overrideAccess: boolean /** The sibling data of the document before changes being applied. */ previousSiblingDoc?: TSiblingData diff --git a/packages/payload/src/auth/getAccessResults.ts b/packages/payload/src/auth/getAccessResults.ts index c7e6717cd9b..8148f52c41c 100644 --- a/packages/payload/src/auth/getAccessResults.ts +++ b/packages/payload/src/auth/getAccessResults.ts @@ -31,7 +31,13 @@ export async function getAccessResults({ await Promise.all( payload.config.collections.map(async (collection) => { - const collectionOperations: AllOperations[] = ['create', 'read', 'update', 'delete'] + const collectionOperations: AllOperations[] = [ + 'create', + 'read', + 'update', + 'delete', + 'validate', + ] if ( collection.auth && @@ -59,7 +65,7 @@ export async function getAccessResults({ await Promise.all( payload.config.globals.map(async (global) => { - const globalOperations: AllOperations[] = ['read', 'update'] + const globalOperations: AllOperations[] = ['read', 'update', 'validate'] if (global.versions) { globalOperations.push('readVersions') diff --git a/packages/payload/src/auth/operations/forgotPassword.ts b/packages/payload/src/auth/operations/forgotPassword.ts index 93c1e58bc58..dd786e80ea2 100644 --- a/packages/payload/src/auth/operations/forgotPassword.ts +++ b/packages/payload/src/auth/operations/forgotPassword.ts @@ -13,6 +13,7 @@ import { buildBeforeOperation } from '../../collections/operations/utilities/bui import { APIError } from '../../errors/index.js' import { Forbidden } from '../../index.js' import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { formatAdminURL } from '../../utilities/formatAdminURL.js' import { getRequestOrigin } from '../../utilities/getRequestOrigin.js' @@ -37,6 +38,8 @@ export type Result = string export const forgotPasswordOperation = async ( incomingArgs: Arguments, ): Promise => { + assertNoValidationWrite(incomingArgs.req) + const loginWithUsername = incomingArgs.collection.config.auth.loginWithUsername const { data, overrideAccess } = incomingArgs diff --git a/packages/payload/src/auth/operations/login.ts b/packages/payload/src/auth/operations/login.ts index 80f839ea180..739e2114391 100644 --- a/packages/payload/src/auth/operations/login.ts +++ b/packages/payload/src/auth/operations/login.ts @@ -17,6 +17,7 @@ import { } from '../../errors/index.js' import { commitTransaction, Forbidden, initTransaction } from '../../index.js' import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { killTransaction } from '../../utilities/killTransaction.js' import { sanitizeInternalFields } from '../../utilities/sanitizeInternalFields.js' import { applyUserReadAccess } from '../applyUserReadAccess.js' @@ -75,6 +76,8 @@ export const loginOperation = async ( ): Promise> => { let args = incomingArgs + assertNoValidationWrite(args.req) + if (args.collection.config.auth.disableLocalStrategy) { throw new Forbidden(args.req.t) } diff --git a/packages/payload/src/auth/operations/logout.ts b/packages/payload/src/auth/operations/logout.ts index 424991f3dd0..3e7019550e1 100644 --- a/packages/payload/src/auth/operations/logout.ts +++ b/packages/payload/src/auth/operations/logout.ts @@ -5,6 +5,7 @@ import type { PayloadRequest } from '../../types/index.js' import { APIError } from '../../errors/index.js' import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' @@ -24,6 +25,8 @@ export const logoutOperation = async (incomingArgs: Arguments): Promise req, } = incomingArgs + assertNoValidationWrite(req) + if (!user) { throw new APIError('No User', httpStatus.BAD_REQUEST) } diff --git a/packages/payload/src/auth/operations/refresh.ts b/packages/payload/src/auth/operations/refresh.ts index b2b8e9ad4d7..58b47566dd0 100644 --- a/packages/payload/src/auth/operations/refresh.ts +++ b/packages/payload/src/auth/operations/refresh.ts @@ -8,6 +8,7 @@ import { buildAfterOperation } from '../../collections/operations/utilities/buil import { buildBeforeOperation } from '../../collections/operations/utilities/buildBeforeOperation.js' import { APIError, Forbidden, NotFound } from '../../errors/index.js' import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' @@ -31,6 +32,8 @@ export type Arguments = { export const refreshOperation = async (incomingArgs: Arguments): Promise => { let args = incomingArgs + assertNoValidationWrite(args.req) + try { const shouldCommit = await initTransaction(args.req) diff --git a/packages/payload/src/auth/operations/resetPassword.ts b/packages/payload/src/auth/operations/resetPassword.ts index f084c2e32b8..3f55c5c9494 100644 --- a/packages/payload/src/auth/operations/resetPassword.ts +++ b/packages/payload/src/auth/operations/resetPassword.ts @@ -8,6 +8,7 @@ import { buildAfterOperation } from '../../collections/operations/utilities/buil import { buildBeforeOperation } from '../../collections/operations/utilities/buildBeforeOperation.js' import { APIError, Forbidden } from '../../errors/index.js' import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' @@ -49,6 +50,8 @@ export const resetPasswordOperation = async ( req, } = args + assertNoValidationWrite(req) + if ( !Object.prototype.hasOwnProperty.call(data, 'token') || !Object.prototype.hasOwnProperty.call(data, 'password') diff --git a/packages/payload/src/auth/operations/verifyEmail.ts b/packages/payload/src/auth/operations/verifyEmail.ts index 83cf8481741..390f6288f83 100644 --- a/packages/payload/src/auth/operations/verifyEmail.ts +++ b/packages/payload/src/auth/operations/verifyEmail.ts @@ -5,6 +5,7 @@ import type { PayloadRequest } from '../../types/index.js' import { APIError, Forbidden } from '../../errors/index.js' import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' @@ -18,6 +19,8 @@ export type Args = { export const verifyEmailOperation = async (args: Args): Promise => { const { collection, req, token } = args + assertNoValidationWrite(req) + if (collection.config.auth.disableLocalStrategy) { throw new Forbidden(req.t) } diff --git a/packages/payload/src/auth/sessions.ts b/packages/payload/src/auth/sessions.ts index 633daabd3b2..6d3bcd32461 100644 --- a/packages/payload/src/auth/sessions.ts +++ b/packages/payload/src/auth/sessions.ts @@ -4,6 +4,8 @@ import type { SanitizedCollectionConfig } from '../collections/config/types.js' import type { AuthenticatedUser, User, UserSession } from '../index.js' import type { Payload, PayloadRequest } from '../types/index.js' +import { assertNoValidationWrite } from '../utilities/assertNoValidationWrite.js' + /** * Removes expired sessions from an array of sessions */ @@ -31,6 +33,8 @@ export const addSessionToUser = async ({ req: PayloadRequest user: AuthenticatedUser }): Promise<{ sid?: string }> => { + assertNoValidationWrite(req) + let sid: string | undefined if (collectionConfig.auth.useSessions) { // Add session to user @@ -82,6 +86,8 @@ export const revokeSession = async ({ sid: string user: null | User }): Promise => { + assertNoValidationWrite(req) + if (collectionConfig.auth.useSessions && user && user.sessions?.length) { user.sessions = user.sessions.filter((session) => session.id !== sid) await payload.db.updateOne({ diff --git a/packages/payload/src/auth/strategies/local/resetLoginAttempts.ts b/packages/payload/src/auth/strategies/local/resetLoginAttempts.ts index 8865612d0ad..0c558b79d5a 100644 --- a/packages/payload/src/auth/strategies/local/resetLoginAttempts.ts +++ b/packages/payload/src/auth/strategies/local/resetLoginAttempts.ts @@ -2,6 +2,8 @@ import type { SanitizedCollectionConfig, TypeWithID } from '../../../collections import type { Payload } from '../../../index.js' import type { PayloadRequest } from '../../../types/index.js' +import { assertNoValidationWrite } from '../../../utilities/assertNoValidationWrite.js' + type Args = { collection: SanitizedCollectionConfig doc: Record & TypeWithID @@ -15,6 +17,8 @@ export const resetLoginAttempts = async ({ payload, req, }: Args): Promise => { + assertNoValidationWrite(req) + if ( !('lockUntil' in doc && typeof doc.lockUntil === 'string') && (!('loginAttempts' in doc) || doc.loginAttempts === 0) diff --git a/packages/payload/src/auth/types.ts b/packages/payload/src/auth/types.ts index fa0655178b0..3335970db5e 100644 --- a/packages/payload/src/auth/types.ts +++ b/packages/payload/src/auth/types.ts @@ -18,6 +18,8 @@ export type BlockPermissions = { fields: FieldsPermissions read: Permission update: Permission + /** Permission to validate candidate block data without saving. */ + validate: Permission } export type SanitizedBlockPermissions = @@ -42,6 +44,8 @@ export type FieldPermissions = { fields?: FieldsPermissions read?: Permission update?: Permission + /** Permission to validate candidate field data without saving. */ + validate?: Permission } export type SanitizedFieldPermissions = @@ -51,6 +55,7 @@ export type SanitizedFieldPermissions = fields?: SanitizedFieldsPermissions read: true update: true + validate: true } | true @@ -69,6 +74,8 @@ export type CollectionPermission = { // Auth-enabled Collections only unlock?: Permission update?: Permission + /** Permission to validate collection candidate data without saving. */ + validate?: Permission } export type SanitizedCollectionPermission = { @@ -80,6 +87,7 @@ export type SanitizedCollectionPermission = { // Auth-enabled Collections only unlock?: true update?: true + validate?: true } export type GlobalPermission = { @@ -87,6 +95,8 @@ export type GlobalPermission = { read?: Permission readVersions?: Permission update?: Permission + /** Permission to validate global candidate data without saving. */ + validate?: Permission } export type SanitizedGlobalPermission = { @@ -94,6 +104,7 @@ export type SanitizedGlobalPermission = { read?: true readVersions?: true update?: true + validate?: true } export type DocumentPermissions = CollectionPermission | GlobalPermission diff --git a/packages/payload/src/auth/withBaseAccess.ts b/packages/payload/src/auth/withBaseAccess.ts index 0118bbc4d6b..4f0a13e29ef 100644 --- a/packages/payload/src/auth/withBaseAccess.ts +++ b/packages/payload/src/auth/withBaseAccess.ts @@ -34,8 +34,12 @@ export const withBaseAccess = (options: Args): Access => { const { baseAccess } = args.req.payload.config const baseAccessFunction = options.entityType === 'collection' - ? baseAccess?.collections?.[options.operation] - : baseAccess?.globals?.[options.operation] + ? options.operation === 'validate' + ? (baseAccess?.collections?.validate ?? baseAccess?.collections?.update) + : baseAccess?.collections?.[options.operation] + : options.operation === 'validate' + ? (baseAccess?.globals?.validate ?? baseAccess?.globals?.update) + : baseAccess?.globals?.[options.operation] if (!baseAccessFunction) { return documentAccess(accessArgs) diff --git a/packages/payload/src/collections/config/defaults.ts b/packages/payload/src/collections/config/defaults.ts index 3522442cdbe..e4b821d97e4 100644 --- a/packages/payload/src/collections/config/defaults.ts +++ b/packages/payload/src/collections/config/defaults.ts @@ -28,6 +28,7 @@ export const defaults: Partial = { read: defaultAccess, unlock: defaultUnlockAccess, update: defaultAccess, + validate: defaultAccess, }, admin: { components: {}, @@ -82,6 +83,7 @@ export const addDefaultsToCollectionConfig = (collection: CollectionConfig): Col readVersions: access?.readVersions ?? createInheritedReadVersionsAccess(read), unlock: access?.unlock ?? defaultUnlockAccess, update: access?.update ?? defaultAccess, + validate: access?.validate ?? access?.update ?? defaultAccess, } satisfies SanitizedCollectionConfig['access'] collection.admin = { diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index c6fb73c7bb1..84321ed7e0f 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -418,7 +418,7 @@ export const sanitizeCollection = ( sanitized.admin!.pagination!.limits = collection.admin.pagination.limits } - for (const operation of ['create', 'delete', 'read', 'unlock', 'update'] as const) { + for (const operation of ['create', 'delete', 'read', 'unlock', 'update', 'validate'] as const) { sanitized.access![operation] = withBaseAccess({ slug: sanitized.slug, access: sanitized.access?.[operation], diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index 021987bbde1..4f1bd990261 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -172,8 +172,10 @@ export type HookOperationType = | 'resetPassword' | 'restoreVersion' | 'update' + | 'validate' type CreateOrUpdateOperation = Extract +type CreateUpdateOrValidateOperation = Extract export type BeforeOperationHook = ( arg: BeforeOperationArg, @@ -191,7 +193,7 @@ export type BeforeValidateHook = (args: { /** * Hook operation being performed */ - operation: CreateOrUpdateOperation + operation: CreateUpdateOrValidateOperation /** * Original document before change * @@ -209,7 +211,7 @@ export type BeforeChangeHook = (args: { /** * Hook operation being performed */ - operation: CreateOrUpdateOperation + operation: CreateUpdateOrValidateOperation /** * Original document before change * @@ -542,6 +544,13 @@ export type CollectionAccess = { readVersions?: Access unlock?: Access update?: Access + /** + * Controls on-demand validation for this collection. + * Falls back to `update` access when omitted. + * The access function receives `req.operation === 'validate'`. + * @see https://payloadcms.com/docs/validation/overview#access-control-and-hooks + */ + validate?: Access } type CollectionHooks = { @@ -834,7 +843,10 @@ export interface SanitizedCollectionConfig _sanitized: true access: Pick & Required< - Pick + Pick< + CollectionAccess, + 'create' | 'delete' | 'read' | 'readVersions' | 'unlock' | 'update' | 'validate' + > > auth: Auth authorship: SanitizedAuthorship diff --git a/packages/payload/src/collections/endpoints/index.ts b/packages/payload/src/collections/endpoints/index.ts index b022f652fd7..3c4407d16c2 100644 --- a/packages/payload/src/collections/endpoints/index.ts +++ b/packages/payload/src/collections/endpoints/index.ts @@ -15,6 +15,7 @@ import { findVersionsHandler } from './findVersions.js' import { restoreVersionHandler } from './restoreVersion.js' import { updateHandler } from './update.js' import { updateByIDHandler } from './updateByID.js' +import { validateByIDHandler, validateHandler } from './validate.js' export const duplicateEndpoint: Endpoint = { handler: duplicateHandler, @@ -49,6 +50,16 @@ export const defaultCollectionEndpoints: Endpoint[] = [ method: 'post', path: '/access/:id?', }, + { + handler: validateHandler, + method: 'post', + path: '/validate', + }, + { + handler: validateByIDHandler, + method: 'post', + path: '/:id/validate', + }, { handler: findVersionsHandler, method: 'get', diff --git a/packages/payload/src/collections/endpoints/validate.ts b/packages/payload/src/collections/endpoints/validate.ts new file mode 100644 index 00000000000..4a0d577750e --- /dev/null +++ b/packages/payload/src/collections/endpoints/validate.ts @@ -0,0 +1,78 @@ +import { status as httpStatus } from 'http-status' + +import type { PayloadHandler } from '../../config/types.js' + +import { + getRequestCollection, + getRequestCollectionWithID, +} from '../../utilities/getRequestEntity.js' +import { headersWithCors } from '../../utilities/headersWithCors.js' +import { + assertValidationData, + parseValidationLocaleSelector, +} from '../../utilities/parseValidationLocale.js' +import { validateLocal } from '../operations/local/validate.js' + +/** + * Validates collection create candidate data. + * + * `POST {routes.api}/{collection}/validate` requires an object body and one or more `locale` query + * parameters, or `locale=all`. Field validation failures return a `200` ValidationResult. + */ +export const validateHandler: PayloadHandler = async (req) => { + const collection = getRequestCollection(req) + const locale = parseValidationLocaleSelector(req.query.locale) + + assertValidationData(req.data) + + const result = await validateLocal(req.payload, { + collection: collection.config.slug, + data: req.data, + locale, + overrideAccess: false, + req, + }) + + return Response.json(result, { + headers: headersWithCors({ + headers: new Headers(), + req, + }), + status: httpStatus.OK, + }) +} + +/** + * Validates a stored collection document with optional partial candidate data. + * + * `POST {routes.api}/{collection}/{id}/validate` accepts an optional object body and requires one + * or more `locale` query parameters, or `locale=all`. The newest available draft is used as the + * base, falling back to the main document. Field validation failures return a `200` + * ValidationResult. + */ +export const validateByIDHandler: PayloadHandler = async (req) => { + const { id, collection } = getRequestCollectionWithID(req) + const locale = parseValidationLocaleSelector(req.query.locale) + + if (req.data !== undefined) { + assertValidationData(req.data) + } + + const result = await validateLocal(req.payload, { + id, + collection: collection.config.slug, + data: req.data, + draft: true, + locale, + overrideAccess: false, + req, + }) + + return Response.json(result, { + headers: headersWithCors({ + headers: new Headers(), + req, + }), + status: httpStatus.OK, + }) +} diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index 0999e8688ff..cdf27b97f0e 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -35,6 +35,7 @@ import { } from '../../uploads/sanitizeUploadData.js' import { unlinkTempFiles } from '../../uploads/unlinkTempFiles.js' import { uploadFiles } from '../../uploads/uploadFiles.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { hasDraftsEnabled, @@ -43,6 +44,7 @@ import { } from '../../utilities/getVersionsConfig.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' +import { resolvePublishAllLocales } from '../../utilities/resolvePublishAllLocales.js' import { resolveSelect } from '../../utilities/resolveSelect.js' import { sanitizeInternalFields } from '../../utilities/sanitizeInternalFields.js' import { sanitizeSelect } from '../../utilities/sanitizeSelect.js' @@ -83,6 +85,8 @@ export const createOperation = async < let args = incomingArgs let externalUploadSource: ReturnType + assertNoValidationWrite(args.req) + try { const shouldCommit = !args.disableTransaction && (await initTransaction(args.req)) @@ -108,8 +112,11 @@ export const createOperation = async < } const initialCollectionConfig = args.collection.config - const initialPublishAllLocales = - !args.draft && (args.publishAllLocales ?? !hasLocalizeStatusEnabled(initialCollectionConfig)) + const initialPublishAllLocales = resolvePublishAllLocales({ + draft: args.draft, + hasLocalizeStatusEnabled: hasLocalizeStatusEnabled(initialCollectionConfig), + publishAllLocalesArg: args.publishAllLocales, + }) const initialAllLocalesPublicationStatus = getAllLocalesPublicationStatus({ hasLocalizedStatus: Boolean( args.req.payload.config.localization && hasLocalizeStatusEnabled(initialCollectionConfig), @@ -168,8 +175,11 @@ export const createOperation = async < let { data } = args // For creates there is no existing doc — always publish all locales when not a draft. - let publishAllLocales = - !draft && (publishAllLocalesArg ?? !hasLocalizeStatusEnabled(collectionConfig)) + let publishAllLocales = resolvePublishAllLocales({ + draft, + hasLocalizeStatusEnabled: hasLocalizeStatusEnabled(collectionConfig), + publishAllLocalesArg, + }) const requestedAllLocalesPublicationStatus = getAllLocalesPublicationStatus({ hasLocalizedStatus: Boolean( config.localization && hasLocalizeStatusEnabled(collectionConfig), diff --git a/packages/payload/src/collections/operations/delete.ts b/packages/payload/src/collections/operations/delete.ts index adf0d5d3f6b..0d8df5eeb24 100644 --- a/packages/payload/src/collections/operations/delete.ts +++ b/packages/payload/src/collections/operations/delete.ts @@ -19,6 +19,7 @@ import { afterRead } from '../../fields/hooks/afterRead/index.js' import { deleteUserPreferences } from '../../preferences/deleteUserPreferences.js' import { deleteAssociatedFiles } from '../../uploads/deleteAssociatedFiles.js' import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { checkDocumentLockStatus, deleteDocumentLocks, @@ -57,6 +58,8 @@ export const deleteOperation = async < ): Promise> => { let args = incomingArgs + assertNoValidationWrite(args.req) + if (args.collection.config.disableBulkDelete && !args.overrideAccess) { throw new APIError(`Collection ${args.collection.config.slug} has disabled bulk delete`, 403) } diff --git a/packages/payload/src/collections/operations/deleteByID.ts b/packages/payload/src/collections/operations/deleteByID.ts index c665c742379..0757c6e3494 100644 --- a/packages/payload/src/collections/operations/deleteByID.ts +++ b/packages/payload/src/collections/operations/deleteByID.ts @@ -15,6 +15,7 @@ import { afterRead } from '../../fields/hooks/afterRead/index.js' import { deleteUserPreferences } from '../../preferences/deleteUserPreferences.js' import { deleteAssociatedFiles } from '../../uploads/deleteAssociatedFiles.js' import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { checkDocumentLockStatus } from '../../utilities/checkDocumentLockStatus.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { hasScheduledPublishEnabled } from '../../utilities/getVersionsConfig.js' @@ -45,6 +46,8 @@ export const deleteByIDOperation = async > => { let args = incomingArgs + assertNoValidationWrite(args.req) + try { const shouldCommit = !args.disableTransaction && (await initTransaction(args.req)) diff --git a/packages/payload/src/collections/operations/docAccess.ts b/packages/payload/src/collections/operations/docAccess.ts index 0a0a854803c..7155860aba7 100644 --- a/packages/payload/src/collections/operations/docAccess.ts +++ b/packages/payload/src/collections/operations/docAccess.ts @@ -5,7 +5,7 @@ import type { Collection } from '../config/types.js' import { getEntityPermissions } from '../../utilities/getEntityPermissions/getEntityPermissions.js' import { sanitizePermissions } from '../../utilities/sanitizePermissions.js' -const allOperations: AllOperations[] = ['create', 'read', 'update', 'delete'] +const allOperations: AllOperations[] = ['create', 'read', 'update', 'delete', 'validate'] type Arguments = { collection: Collection diff --git a/packages/payload/src/collections/operations/local/validate.ts b/packages/payload/src/collections/operations/local/validate.ts new file mode 100644 index 00000000000..3249fbed31a --- /dev/null +++ b/packages/payload/src/collections/operations/local/validate.ts @@ -0,0 +1,191 @@ +import type { DeepPartial } from 'ts-essentials' + +import { status as httpStatus } from 'http-status' + +import type { + CollectionSlug, + Payload, + RequestContext, + User, + ValidationFieldError, +} from '../../../index.js' +import type { PayloadRequest } from '../../../types/index.js' +import type { ValidationLocaleSelector } from '../../../utilities/resolveValidationLocales.js' +import type { + DataFromCollectionSlug, + DraftFlagFromCollectionSlug, + RequiredDataFromCollectionSlug, +} from '../../config/types.js' + +import { APIError } from '../../../errors/index.js' +import { runLocaleScopedValidation } from '../../../utilities/runLocaleScopedValidation.js' +import { validateOperation } from '../validate.js' + +/** + * The result of validating a collection or global document candidate without persisting it. + * + * Field validation failures are returned in this result. Access denials, invalid arguments, + * missing documents, and other lifecycle errors throw instead. + */ +export type ValidationResult = { + /** + * Field validation errors. Errors from localized passes are tagged with the locale that failed; + * non-localized validation may omit the locale. + * Empty when {@link valid} is `true`. + */ + errors: ValidationFieldError[] + /** Whether the candidate passed field validation in every selected locale. */ + valid: boolean +} + +type BaseOptions = { + /** The collection slug to validate against. */ + collection: TSlug + /** + * Hook context merged into `req.context` for the validation lifecycle. + */ + context?: RequestContext + /** + * A locale, a non-empty locale array, or `'all'`. + * + * Each selected locale receives an independent copy of the same candidate `data`. + * `'all'` resolves through `localization.filterAvailableLocales` when configured. Use `null` + * for projects without localization. + */ + locale: ValidationLocaleSelector + /** + * Skip collection and field access control. + * @default false + */ + overrideAccess?: boolean + /** + * An existing request to reuse for user, locale, and context. + */ + req?: Partial + /** + * The user used by access control when `overrideAccess` is `false`. + */ + user?: null | User +} & DraftFlagFromCollectionSlug + +/** + * Options for validating a collection document without persisting it. + * + * Omitting `id` validates create candidate data. Supplying `id` loads the stored main document by + * default. Set `draft: true` to use the newest available draft version, falling back to the main + * document. Optional partial data is merged over that base. Access control, hooks, field access, + * and validators receive the first-class `validate` operation in both cases. + */ +export type ValidateCollectionOptions = + | ({ + /** + * Candidate create data. This property is required, but its fields may be incomplete or + * invalid so callers can inspect the returned errors. + */ + data: DeepPartial> + /** Create candidate validation does not accept a document ID. */ + id?: never + } & BaseOptions) + | ({ + /** Optional partial candidate data to merge over the selected stored document. */ + data?: DeepPartial> + /** ID of the stored document used as the candidate's base. */ + id: DataFromCollectionSlug['id'] + } & BaseOptions) + +type InternalValidateCollectionOptions = { + /** + * Whether `data` stores each localized field as a locale-code-keyed object, as the internal + * publish-all-locales candidate does, rather than a flat, single-locale candidate. + */ + dataIsLocaleKeyed?: boolean + validationDataLocale?: string + validationTrash?: boolean +} & ValidateCollectionOptions + +export async function validateLocal( + payload: Payload, + options: ValidateCollectionOptions, +): Promise { + const publicOptions = { + collection: options.collection, + context: options.context, + draft: options.draft, + locale: options.locale, + overrideAccess: options.overrideAccess, + req: options.req, + user: options.user, + } + + // Both branches call the same function with the same data; the split exists only because + // `InternalValidateCollectionOptions`'s `id` follows the same discriminated union as the public + // `ValidateCollectionOptions`, so `id` must be omitted entirely rather than passed as `undefined`. + if (options.id === undefined) { + return validateLocalWithDataLocale(payload, { + ...publicOptions, + data: options.data, + }) + } + + return validateLocalWithDataLocale(payload, { + ...publicOptions, + id: options.id, + data: options.data, + }) +} + +export async function validateLocalWithDataLocale( + payload: Payload, + options: InternalValidateCollectionOptions, +): Promise { + const { + id, + collection: collectionSlug, + data, + dataIsLocaleKeyed, + draft = false, + locale, + overrideAccess = false, + validationDataLocale, + validationTrash, + } = options + + if (locale === undefined) { + throw new APIError('Validation requires a locale.', httpStatus.BAD_REQUEST) + } + + if (id === undefined && data === undefined) { + throw new APIError('Validation create simulation requires data.', httpStatus.BAD_REQUEST) + } + + const collection = payload.collections[collectionSlug] + + if (!collection) { + throw new APIError( + `The collection with slug ${String(collectionSlug)} can't be found. Validate Operation.`, + ) + } + + return runLocaleScopedValidation({ + context: options.context, + data, + fields: collection.config.fields, + locale, + payload, + req: options.req, + runPass: ({ data: validationData, onValidationData, req }) => + validateOperation({ + id, + collection, + data: validationData, + dataIsLocaleKeyed, + draft, + onValidationData, + overrideAccess, + req, + trash: validationTrash, + }), + user: options.user, + validationDataLocale, + }) +} diff --git a/packages/payload/src/collections/operations/restoreVersion.ts b/packages/payload/src/collections/operations/restoreVersion.ts index 58fab9a115c..75cf6f3cdf5 100644 --- a/packages/payload/src/collections/operations/restoreVersion.ts +++ b/packages/payload/src/collections/operations/restoreVersion.ts @@ -24,6 +24,7 @@ import { restoreUploadDataFromDocument, sanitizeUploadData, } from '../../uploads/sanitizeUploadData.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { deepCopyObjectSimple } from '../../utilities/deepCopyObject.js' import { hasDraftValidationEnabled } from '../../utilities/getVersionsConfig.js' @@ -56,6 +57,8 @@ export const restoreVersionOperation = async < >( args: Arguments, ): Promise => { + assertNoValidationWrite(args.req) + const { id, collection: { config: collectionConfig }, diff --git a/packages/payload/src/collections/operations/update.ts b/packages/payload/src/collections/operations/update.ts index 90becee4aa1..df4aaf7259f 100644 --- a/packages/payload/src/collections/operations/update.ts +++ b/packages/payload/src/collections/operations/update.ts @@ -31,6 +31,7 @@ import { } from '../../uploads/sanitizeUploadData.js' import { unlinkTempFiles } from '../../uploads/unlinkTempFiles.js' import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { hasDraftsEnabled, hasLocalizeStatusEnabled } from '../../utilities/getVersionsConfig.js' import { initTransaction } from '../../utilities/initTransaction.js' @@ -89,6 +90,8 @@ export const updateOperation = async < ): Promise> => { let args = incomingArgs + assertNoValidationWrite(args.req) + if (args.collection.config.disableBulkEdit && !args.overrideAccess) { throw new APIError(`Collection ${args.collection.config.slug} has disabled bulk edit`, 403) } diff --git a/packages/payload/src/collections/operations/updateByID.ts b/packages/payload/src/collections/operations/updateByID.ts index 5374643709c..eb32a6a1015 100644 --- a/packages/payload/src/collections/operations/updateByID.ts +++ b/packages/payload/src/collections/operations/updateByID.ts @@ -30,6 +30,7 @@ import { } from '../../uploads/sanitizeUploadData.js' import { unlinkTempFiles } from '../../uploads/unlinkTempFiles.js' import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { hasLocalizeStatusEnabled } from '../../utilities/getVersionsConfig.js' import { initTransaction } from '../../utilities/initTransaction.js' @@ -75,6 +76,8 @@ export const updateByIDOperation = async < ): Promise> => { let args = incomingArgs + assertNoValidationWrite(args.req) + try { const shouldCommit = !args.disableTransaction && (await initTransaction(args.req)) diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index 828540f3903..2d4fb351545 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -41,6 +41,7 @@ import { hasDraftValidationEnabled, hasLocalizeStatusEnabled, } from '../../../utilities/getVersionsConfig.js' +import { resolvePublishAllLocales } from '../../../utilities/resolvePublishAllLocales.js' import { buildAllLocalesPublicationHookDoc, getAllLocalesPublicationStatus, @@ -113,9 +114,12 @@ export const updateDocument = async < unpublishAllLocales: unpublishAllLocalesArg, }) - const publishAllLocales = - !draftArg && - (publishAllLocalesArg ?? !(hasLocalizeStatusEnabled(collectionConfig) && locale !== 'all')) + const publishAllLocales = resolvePublishAllLocales({ + draft: draftArg, + hasLocalizeStatusEnabled: hasLocalizeStatusEnabled(collectionConfig), + locale, + publishAllLocalesArg, + }) const unpublishAllLocales = typeof unpublishAllLocalesArg === 'string' ? unpublishAllLocalesArg === 'true' @@ -158,7 +162,6 @@ export const updateDocument = async < req, showHiddenFields: true, }) - const isRestoringDraftFromTrash = Boolean(originalDoc?.deletedAt) && data?._status !== 'published' const shouldLimitValidationToSubmittedFields = (collectionConfig.trash && (Boolean(data?.deletedAt) || isRestoringDraftFromTrash)) || @@ -178,28 +181,13 @@ export const updateDocument = async < }) } - // ///////////////////////////////////// - // Delete any associated files - // ///////////////////////////////////// - // When saving a draft on a document whose latest version is published, the file // referenced by docWithLocales is still actively used by the published main document. - // Deleting it here would break the published document's file even though no publish + // Deleting it during the update would break the published document's file even though no publish // is happening. Only skip deletion in this case; when the latest version is already a // draft, it is safe to delete the old draft file as it is being replaced. const isDraftOverPublished = isSavingDraft && docWithLocales._status === 'published' - if (!isDraftOverPublished) { - await deleteAssociatedFiles({ - collectionConfig, - config, - doc: docWithLocales, - files: filesToUpload, - overrideDelete: false, - req, - }) - } - // ///////////////////////////////////// // beforeValidate - Fields // ///////////////////////////////////// @@ -266,14 +254,6 @@ export const updateDocument = async < } } - // ///////////////////////////////////// - // Write files to local storage - // ///////////////////////////////////// - - if (!collectionConfig.upload.disableLocalStorage) { - await uploadFiles(payload, filesToUpload, req) - } - // ///////////////////////////////////// // beforeChange - Collection // ///////////////////////////////////// @@ -344,6 +324,23 @@ export const updateDocument = async < result._status = { ...docWithLocales._status } } + // File deletion and writes must occur after beforeChange's field validation. Validation + // failures leave both the persisted upload and local files untouched. + if (!isDraftOverPublished) { + await deleteAssociatedFiles({ + collectionConfig, + config, + doc: docWithLocales, + files: filesToUpload, + overrideDelete: false, + req, + }) + } + + if (!collectionConfig.upload.disableLocalStorage) { + await uploadFiles(payload, filesToUpload, req) + } + if ( config.localization && hasLocalizeStatusEnabled(collectionConfig) && diff --git a/packages/payload/src/collections/operations/validate.ts b/packages/payload/src/collections/operations/validate.ts new file mode 100644 index 00000000000..f46b5250bcd --- /dev/null +++ b/packages/payload/src/collections/operations/validate.ts @@ -0,0 +1,258 @@ +import type { DeepPartial } from 'ts-essentials' + +import type { FindOneArgs } from '../../database/types.js' +import type { CollectionSlug, JsonObject } from '../../index.js' +import type { PayloadRequest } from '../../types/index.js' +import type { Collection, RequiredDataFromCollectionSlug, TypeWithID } from '../config/types.js' +import type { ValidationResult } from './local/validate.js' + +import { ensureUsernameOrEmail } from '../../auth/ensureUsernameOrEmail.js' +import { executeAccess } from '../../auth/executeAccess.js' +import { hasWhereAccessResult } from '../../auth/types.js' +import { combineQueries } from '../../database/combineQueries.js' +import { Forbidden, NotFound } from '../../errors/index.js' +import { afterRead } from '../../fields/hooks/afterRead/index.js' +import { beforeChange } from '../../fields/hooks/beforeChange/index.js' +import { beforeValidate } from '../../fields/hooks/beforeValidate/index.js' +import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' +import { deepCopyObjectSimple } from '../../utilities/deepCopyObject.js' +import { deepMergeWithSourceArraysIgnoringUndefined } from '../../utilities/deepMerge.js' +import { flattenDataByLocale } from '../../utilities/flattenDataByLocale.js' +import { toValidationResult } from '../../utilities/toValidationResult.js' +import { appendVersionToQueryKey } from '../../versions/drafts/appendVersionToQueryKey.js' + +export type Arguments = { + collection: Collection + data?: DeepPartial> + /** + * Whether `data` stores each localized field as a locale-code-keyed object, as the internal + * publish-all-locales candidate does, rather than a flat, single-locale candidate. + * @default false + */ + dataIsLocaleKeyed?: boolean + draft: boolean + id?: number | string + onValidationData?: (data: JsonObject) => void + overrideAccess: boolean + req: PayloadRequest + trash?: boolean +} + +export async function validateOperation( + args: Arguments, +): Promise { + const previousOperation = args.req.operation + args.req.operation = 'validate' + + try { + return await validateOperationWithScopedRequest(args) + } finally { + args.req.operation = previousOperation + } +} + +async function validateOperationWithScopedRequest({ + id, + collection, + data: incomingData, + dataIsLocaleKeyed = false, + draft, + onValidationData, + overrideAccess, + req, + trash, +}: Arguments): Promise { + const collectionConfig = collection.config + + const accessResult = !overrideAccess + ? await executeAccess( + { id, slug: collectionConfig.slug, data: incomingData, req }, + collectionConfig.access.validate, + ) + : true + const hasWherePolicy = hasWhereAccessResult(accessResult) + + if (id === undefined && hasWherePolicy) { + throw new Forbidden(req.t) + } + + let docWithLocales: JsonObject = {} + + if (id !== undefined) { + const idWhere = appendNonTrashedFilter({ + enableTrash: collectionConfig.trash, + trash: Boolean(trash), + where: { id: { equals: id } }, + }) + const where = combineQueries(idWhere, accessResult) + const query: FindOneArgs = { + collection: collectionConfig.slug, + locale: req.locale!, + req, + where, + } + + let storedDocument: (RequiredDataFromCollectionSlug & TypeWithID) | undefined + + if (draft && collectionConfig.versions?.drafts) { + const { docs } = await req.payload.db.queryDrafts< + RequiredDataFromCollectionSlug & TypeWithID + >({ + collection: collectionConfig.slug, + limit: 1, + locale: req.locale!, + pagination: false, + req, + where: appendVersionToQueryKey(where), + }) + + storedDocument = docs[0] + + if (!storedDocument && hasWherePolicy) { + const { docs: existingVersions } = await req.payload.db.queryDrafts({ + collection: collectionConfig.slug, + limit: 1, + locale: req.locale!, + pagination: false, + req, + select: { parent: true }, + where: appendVersionToQueryKey(idWhere), + }) + + if (existingVersions[0]) { + throw new Forbidden(req.t) + } + } + } + + if (!storedDocument) { + storedDocument = + (await req.payload.db.findOne & TypeWithID>({ + ...query, + req, + })) ?? undefined + } + + if (!storedDocument && hasWherePolicy) { + throw new Forbidden(req.t) + } + if (!storedDocument) { + throw new NotFound(req.t) + } + + docWithLocales = deepCopyObjectSimple(storedDocument) + } + + const originalDoc = + id === undefined + ? docWithLocales + : await afterRead({ + collection: collectionConfig, + context: req.context, + depth: 0, + doc: deepCopyObjectSimple(docWithLocales), + draft, + fallbackLocale: null, + global: null, + locale: req.locale!, + overrideAccess: true, + req, + showHiddenFields: true, + }) + + let data = flattenDataByLocale({ + configBlockReferences: req.payload.config.blocks, + dataIsLocaleKeyed, + docWithLocales: deepCopyObjectSimple(incomingData ?? {}) as JsonObject, + fields: collectionConfig.fields, + locale: req.locale!, + }) + + try { + onValidationData?.(deepMergeWithSourceArraysIgnoringUndefined(originalDoc, data)) + + if (collectionConfig.auth) { + if (id === undefined) { + ensureUsernameOrEmail({ + authOptions: collectionConfig.auth, + collectionSlug: collectionConfig.slug, + data: data as RequiredDataFromCollectionSlug, + operation: 'create', + req, + }) + } else { + ensureUsernameOrEmail({ + authOptions: collectionConfig.auth, + collectionSlug: collectionConfig.slug, + data: data as RequiredDataFromCollectionSlug, + operation: 'update', + originalDoc: originalDoc as RequiredDataFromCollectionSlug, + req, + }) + } + } + + data = await beforeValidate({ + id, + collection: collectionConfig, + context: req.context, + data, + doc: originalDoc, + global: null, + operation: 'validate', + overrideAccess, + req, + }) + onValidationData?.(data) + + if (collectionConfig.hooks.beforeValidate?.length) { + for (const hook of collectionConfig.hooks.beforeValidate) { + data = + (await hook({ + collection: collectionConfig, + context: req.context, + data, + operation: 'validate', + originalDoc, + req, + })) || data + } + } + + if (collectionConfig.hooks.beforeChange?.length) { + for (const hook of collectionConfig.hooks.beforeChange) { + data = + (await hook({ + collection: collectionConfig, + context: req.context, + data, + operation: 'validate', + originalDoc, + req, + })) || data + } + } + + onValidationData?.(data) + + await beforeChange({ + id, + collection: collectionConfig, + context: req.context, + data: id === undefined ? data : { ...data, id }, + doc: originalDoc, + docWithLocales, + global: null, + operation: 'validate', + overrideAccess, + req, + }) + } catch (error) { + return toValidationResult({ error, req }) + } + + return { + errors: [], + valid: true, + } +} diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index f16a6d6a727..6493faae4d1 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -400,6 +400,7 @@ export type GraphQLInfo = { groupTypes: Record localeInputType?: GraphQL.GraphQLEnumType | GraphQL.GraphQLScalarType tabTypes: Record + validationResultType?: GraphQL.GraphQLObjectType } } export type GraphQLExtension = ( @@ -720,6 +721,8 @@ export type LocalizationConfigWithLabels = Prettify< } & BaseLocalizationConfig > +export type SanitizedLocale = Locale + export type SanitizedLocalizationConfig = Prettify< { /** @@ -727,7 +730,8 @@ export type SanitizedLocalizationConfig = Prettify< * @example `["en", "es", "fr", "nl", "de", "jp"]` */ localeCodes: string[] - } & Omit & + locales: SanitizedLocale[] + } & Omit & Required> > diff --git a/packages/payload/src/errors/ValidationError.ts b/packages/payload/src/errors/ValidationError.ts index 2e9b8d1f288..4287c4d7402 100644 --- a/packages/payload/src/errors/ValidationError.ts +++ b/packages/payload/src/errors/ValidationError.ts @@ -12,10 +12,15 @@ import { APIError } from './APIError.js' export const ValidationErrorName = 'ValidationError' export type ValidationFieldError = { + /** Configured field label, when available. */ label?: LabelFunction | StaticLabel - // The error message to display for this field + /** Locale for a localized validation pass. Omitted for non-localized validation. */ + locale?: string + /** Error message to display for this field. */ message: string + /** Dot-separated path to the invalid field. */ path: string + /** Database table associated with the invalid field, when applicable. */ tableName?: string } @@ -47,30 +52,34 @@ export class ValidationError extends APIError<{ // delete to avoid logging the whole req delete results['req'] + const spansMultipleLocales = new Set(results.errors.map((f) => f.locale)).size > 1 + super( `${message} ${results.errors .map((f) => { + const localePrefix = spansMultipleLocales && f.locale ? `[${f.locale}] ` : '' + if (f.label) { if (typeof f.label === 'function') { if (!req || !req.i18n || !req.t) { - return f.path + return `${localePrefix}${f.path}` } - return f.label({ i18n: req.i18n, t: req.t }) + return `${localePrefix}${f.label({ i18n: req.i18n, t: req.t })}` } if (typeof f.label === 'object') { if (req?.i18n?.language) { - return f.label[req.i18n.language] + return `${localePrefix}${f.label[req.i18n.language]}` } - return f.label[Object.keys(f.label)[0]!] + return `${localePrefix}${f.label[Object.keys(f.label)[0]!]}` } - return f.label + return `${localePrefix}${f.label}` } - return f.path + return `${localePrefix}${f.path}` }) .join(', ')}`, httpStatus.BAD_REQUEST, diff --git a/packages/payload/src/fields/config/sanitize.ts b/packages/payload/src/fields/config/sanitize.ts index ac6ed27dcb0..87a6766a645 100644 --- a/packages/payload/src/fields/config/sanitize.ts +++ b/packages/payload/src/fields/config/sanitize.ts @@ -380,6 +380,8 @@ export const sanitizeField = ({ field.access = {} } + field.access.validate = field.access.validate ?? field.access.update + setDefaultBeforeDuplicate(field, parentIsLocalized) } diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 0dca99a64c6..9456b578522 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -147,8 +147,8 @@ import type { import type { DocumentPreferences } from '../../preferences/types.js' import type { DefaultValue, + FieldOperation, JsonObject, - Operation, PayloadRequest, PickPreserveOptional, Where, @@ -205,7 +205,7 @@ export type FieldHookArgs = ( /** * A string relating to which operation the field type is currently executing within. */ - operation: Operation + operation: FieldOperation /** * The path of the field, e.g. ["group", "myArray", 1, "textField"]. The path is the schemaPath but with indexes and would be used in the context of field data, not field schemas. */ @@ -443,7 +443,7 @@ export type BaseValidateOptions = { data: Partial event?: 'onChange' | 'submit' id?: number | string - operation?: Operation + operation?: FieldOperation /** * The `overrideAccess` flag that was attached to the request. This is used to bypass access control checks for fields. */ @@ -510,6 +510,13 @@ export interface FieldBase { create?: FieldAccess read?: FieldAccess update?: FieldAccess + /** + * Controls whether candidate field data participates in on-demand validation. + * Falls back to `update` access when omitted. + * The access function receives `req.operation === 'validate'`. + * @see https://payloadcms.com/docs/validation/overview#access-control-and-hooks + */ + validate?: FieldAccess } admin?: FieldAdmin /** Extension point to add your custom data. Server only. */ @@ -1676,6 +1683,7 @@ export type JoinField = { create?: never read?: FieldAccess update?: never + validate?: never } admin?: { allowCreate?: boolean diff --git a/packages/payload/src/fields/hooks/beforeChange/index.ts b/packages/payload/src/fields/hooks/beforeChange/index.ts index 3fca5675e2b..344ee76a7e7 100644 --- a/packages/payload/src/fields/hooks/beforeChange/index.ts +++ b/packages/payload/src/fields/hooks/beforeChange/index.ts @@ -2,7 +2,7 @@ import type { SanitizedCollectionConfig } from '../../../collections/config/type import type { ValidationFieldError } from '../../../errors/index.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' import type { RequestContext } from '../../../index.js' -import type { JsonObject, Operation, PayloadRequest } from '../../../types/index.js' +import type { FieldOperation, JsonObject, PayloadRequest } from '../../../types/index.js' import { ValidationError } from '../../../errors/index.js' import { deepCopyObjectSimple } from '../../../utilities/deepCopyObject.js' @@ -22,7 +22,7 @@ export type Args = { global: null | SanitizedGlobalConfig id?: number | string onDataProcessed?: (data: T) => void - operation: Operation + operation: FieldOperation overrideAccess?: boolean req: PayloadRequest skipValidation?: boolean diff --git a/packages/payload/src/fields/hooks/beforeChange/promise.ts b/packages/payload/src/fields/hooks/beforeChange/promise.ts index 7dbe69d59e8..9380dc42405 100644 --- a/packages/payload/src/fields/hooks/beforeChange/promise.ts +++ b/packages/payload/src/fields/hooks/beforeChange/promise.ts @@ -2,7 +2,7 @@ import type { RichTextAdapter } from '../../../admin/RichText.js' import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' import type { ValidationFieldError } from '../../../errors/index.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' -import type { JsonObject, Operation, PayloadRequest } from '../../../types/index.js' +import type { FieldOperation, JsonObject, PayloadRequest } from '../../../types/index.js' import type { Block, Field, TabAsField, Validate } from '../../config/types.js' import { MissingEditorProp } from '../../../errors/index.js' @@ -46,7 +46,7 @@ type Args = { global: null | SanitizedGlobalConfig id?: number | string mergeLocaleActions: (() => Promise | void)[] - operation: Operation + operation: FieldOperation overrideAccess: boolean parentIndexPath: string parentIsLocalized: boolean @@ -130,11 +130,15 @@ export const promise = async ({ let skipValidationFromHere = skipValidation || isOutsideSubmittedFieldScope || !passesCondition if (fieldAffectsData(field)) { - // skip validation if the field is localized and the incoming data is null - if (fieldShouldBeLocalized({ field, parentIsLocalized }) && operationLocale !== defaultLocale) { - if (['array', 'blocks'].includes(field.type) && siblingData[field.name!] === null) { - skipValidationFromHere = true - } + const shouldSkipValidationForLocalizedFallback = + operation !== 'validate' && + fieldShouldBeLocalized({ field, parentIsLocalized }) && + operationLocale !== defaultLocale && + ['array', 'blocks'].includes(field.type) && + siblingData[field.name!] === null + + if (shouldSkipValidationForLocalizedFallback) { + skipValidationFromHere = true } // Execute hooks @@ -258,6 +262,7 @@ export const promise = async ({ errors.push({ label: blockLabelPath, + locale: req.locale ?? undefined, message: req.t('validation:invalidBlock', { block: block.blockType }), path: `${path}.${rowIndex}.id`, }) @@ -276,6 +281,7 @@ export const promise = async ({ errors.push({ label: fieldLabel, + locale: req.locale ?? undefined, message: validationResult, path, }) diff --git a/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts b/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts index dee591511fa..7deddf7270d 100644 --- a/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts +++ b/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts @@ -2,7 +2,7 @@ import type { SanitizedCollectionConfig } from '../../../collections/config/type import type { ValidationFieldError } from '../../../errors/index.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' import type { RequestContext } from '../../../index.js' -import type { JsonObject, Operation, PayloadRequest } from '../../../types/index.js' +import type { FieldOperation, JsonObject, PayloadRequest } from '../../../types/index.js' import type { Field, TabAsField } from '../../config/types.js' import { promise } from './promise.js' @@ -34,7 +34,7 @@ type Args = { global: null | SanitizedGlobalConfig id?: number | string mergeLocaleActions: (() => Promise | void)[] - operation: Operation + operation: FieldOperation overrideAccess: boolean parentIndexPath: string /** diff --git a/packages/payload/src/fields/hooks/beforeValidate/index.ts b/packages/payload/src/fields/hooks/beforeValidate/index.ts index a1efd9985ee..33baaa2bb74 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/index.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/index.ts @@ -15,7 +15,7 @@ type Args = { global: null | SanitizedGlobalConfig id?: number | string onFieldAccess?: (args: { accessResult: boolean; path: string }) => void - operation: 'create' | 'update' + operation: 'create' | 'update' | 'validate' overrideAccess: boolean req: PayloadRequest } diff --git a/packages/payload/src/fields/hooks/beforeValidate/promise.ts b/packages/payload/src/fields/hooks/beforeValidate/promise.ts index 8322ba99846..cdfcf04c640 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/promise.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/promise.ts @@ -31,7 +31,7 @@ type Args = { global: null | SanitizedGlobalConfig id?: number | string onFieldAccess?: (args: { accessResult: boolean; path: string }) => void - operation: 'create' | 'update' + operation: 'create' | 'update' | 'validate' overrideAccess: boolean parentIndexPath: string parentIsLocalized: boolean @@ -360,7 +360,8 @@ export const promise = async ({ onFieldAccess?.({ accessResult, path }) if (typeof siblingData[field.name!] === 'undefined' && !req.context?.isRestoringVersion) { - const isDocumentValueAllowed = operation === 'update' || accessResult + const isDocumentValueAllowed = + operation === 'update' || operation === 'validate' || accessResult siblingData[field.name!] = !fallbackResult.executed || !isDocumentValueAllowed diff --git a/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts b/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts index 93a87af16e6..6869a9c0782 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts @@ -24,7 +24,7 @@ type Args = { global: null | SanitizedGlobalConfig id?: number | string onFieldAccess?: (args: { accessResult: boolean; path: string }) => void - operation: 'create' | 'update' + operation: 'create' | 'update' | 'validate' overrideAccess: boolean parentIndexPath: string /** diff --git a/packages/payload/src/globals/config/sanitize.ts b/packages/payload/src/globals/config/sanitize.ts index 50ff7177901..6bf35a573ca 100644 --- a/packages/payload/src/globals/config/sanitize.ts +++ b/packages/payload/src/globals/config/sanitize.ts @@ -62,6 +62,10 @@ export const sanitizeGlobal = ( global.access.update = defaultAccess } + if (!global.access.validate) { + global.access.validate = global.access.update + } + if (!global.hooks.beforeValidate) { global.hooks.beforeValidate = [] } @@ -244,7 +248,7 @@ export const sanitizeGlobal = ( }) } - for (const operation of ['read', 'update'] as const) { + for (const operation of ['read', 'update', 'validate'] as const) { global.access[operation] = withBaseAccess({ slug: global.slug, access: global.access[operation], diff --git a/packages/payload/src/globals/config/types.ts b/packages/payload/src/globals/config/types.ts index 7b9f60a9691..07fa885bd01 100644 --- a/packages/payload/src/globals/config/types.ts +++ b/packages/payload/src/globals/config/types.ts @@ -36,6 +36,13 @@ export type GlobalAccess = { read?: Access readVersions?: Access update?: Access + /** + * Controls on-demand validation for this global. + * Falls back to `update` access when omitted. + * The access function receives `req.operation === 'validate'`. + * @see https://payloadcms.com/docs/validation/overview#access-control-and-hooks + */ + validate?: Access } /** @@ -78,6 +85,8 @@ export type BeforeValidateHook = (args: { data?: any /** The global which this hook is being run on */ global: SanitizedGlobalConfig + /** Hook operation being performed. */ + operation: 'update' | 'validate' originalDoc?: any /** * Whether access control is being overridden for this operation @@ -91,6 +100,8 @@ export type BeforeChangeHook = (args: { data: any /** The global which this hook is being run on */ global: SanitizedGlobalConfig + /** Hook operation being performed. */ + operation: 'update' | 'validate' originalDoc?: any /** * Whether access control is being overridden for this operation @@ -105,6 +116,8 @@ export type AfterChangeHook = (args: { doc: any /** The global which this hook is being run on */ global: SanitizedGlobalConfig + /** Hook operation being performed. */ + operation: 'update' /** * Whether access control is being overridden for this operation */ @@ -281,7 +294,7 @@ export interface SanitizedGlobalConfig >, Required> { _sanitized: true - access: Required> + access: Required> authorship: SanitizedAuthorship endpoints: Endpoint[] | false /** diff --git a/packages/payload/src/globals/endpoints/index.ts b/packages/payload/src/globals/endpoints/index.ts index 55341c583bf..9e8bcc620e5 100644 --- a/packages/payload/src/globals/endpoints/index.ts +++ b/packages/payload/src/globals/endpoints/index.ts @@ -7,6 +7,7 @@ import { findVersionByIDHandler } from './findVersionByID.js' import { findVersionsHandler } from './findVersions.js' import { restoreVersionHandler } from './restoreVersion.js' import { updateHandler } from './update.js' +import { validateHandler } from './validate.js' export const defaultGlobalEndpoints: Endpoint[] = wrapInternalEndpoints([ { @@ -14,6 +15,11 @@ export const defaultGlobalEndpoints: Endpoint[] = wrapInternalEndpoints([ method: 'post', path: '/access', }, + { + handler: validateHandler, + method: 'post', + path: '/validate', + }, { handler: findOneHandler, method: 'get', diff --git a/packages/payload/src/globals/endpoints/validate.ts b/packages/payload/src/globals/endpoints/validate.ts new file mode 100644 index 00000000000..f8119043ae6 --- /dev/null +++ b/packages/payload/src/globals/endpoints/validate.ts @@ -0,0 +1,44 @@ +import { status as httpStatus } from 'http-status' + +import type { PayloadHandler } from '../../config/types.js' + +import { getRequestGlobal } from '../../utilities/getRequestEntity.js' +import { headersWithCors } from '../../utilities/headersWithCors.js' +import { + assertValidationData, + parseValidationLocaleSelector, +} from '../../utilities/parseValidationLocale.js' +import { validateGlobalLocal } from '../operations/local/validate.js' + +/** + * Validates a global with optional partial candidate data. + * + * `POST {routes.api}/globals/{global}/validate` accepts an optional object body and requires one or + * more `locale` query parameters, or `locale=all`. The newest available draft is used as the base, + * falling back to the main global. Field validation failures return a `200` ValidationResult. + */ +export const validateHandler: PayloadHandler = async (req) => { + const globalConfig = getRequestGlobal(req) + const locale = parseValidationLocaleSelector(req.query.locale) + + if (req.data !== undefined) { + assertValidationData(req.data) + } + + const result = await validateGlobalLocal(req.payload, { + slug: globalConfig.slug, + data: req.data, + draft: true, + locale, + overrideAccess: false, + req, + }) + + return Response.json(result, { + headers: headersWithCors({ + headers: new Headers(), + req, + }), + status: httpStatus.OK, + }) +} diff --git a/packages/payload/src/globals/operations/docAccess.ts b/packages/payload/src/globals/operations/docAccess.ts index 1614fd046d2..56bc81dae09 100644 --- a/packages/payload/src/globals/operations/docAccess.ts +++ b/packages/payload/src/globals/operations/docAccess.ts @@ -17,7 +17,7 @@ type Arguments = { export const docAccessOperation = async (args: Arguments): Promise => { const { data, globalConfig, req } = args - const globalOperations: AllOperations[] = ['read', 'update'] + const globalOperations: AllOperations[] = ['read', 'update', 'validate'] if (globalConfig.versions) { globalOperations.push('readVersions') diff --git a/packages/payload/src/globals/operations/local/validate.ts b/packages/payload/src/globals/operations/local/validate.ts new file mode 100644 index 00000000000..8fa82107574 --- /dev/null +++ b/packages/payload/src/globals/operations/local/validate.ts @@ -0,0 +1,119 @@ +import type { DeepPartial } from 'ts-essentials' + +import { status as httpStatus } from 'http-status' + +import type { ValidationResult } from '../../../collections/operations/local/validate.js' +import type { GlobalSlug, Payload, RequestContext, User } from '../../../index.js' +import type { PayloadRequest } from '../../../types/index.js' +import type { ValidationLocaleSelector } from '../../../utilities/resolveValidationLocales.js' +import type { DataFromGlobalSlug, DraftFlagFromGlobalSlug } from '../../config/types.js' + +import { APIError } from '../../../errors/index.js' +import { runLocaleScopedValidation } from '../../../utilities/runLocaleScopedValidation.js' +import { validateOperation } from '../validate.js' + +/** + * Options for validating a global document without persisting it. + * + * The stored main global is loaded by default. Set `draft: true` to use the newest available draft + * version, falling back to the main global. Optional partial candidate data is merged over that + * base. Access control, hooks, field access, and validators receive the first-class `validate` + * operation. + */ +export type ValidateGlobalOptions = { + /** Hook context merged into `req.context` for the validation lifecycle. */ + context?: RequestContext + /** Optional partial candidate data to merge over the selected stored global. */ + data?: DeepPartial, 'id'>> + /** + * A locale, a non-empty locale array, or `'all'`. + * + * Each selected locale receives an independent copy of the same candidate `data`. + * `'all'` resolves through `localization.filterAvailableLocales` when configured. Use `null` + * for projects without localization. + */ + locale: ValidationLocaleSelector + /** + * Skip global and field access control. + * @default false + */ + overrideAccess?: boolean + /** An existing request to reuse for user, locale, and context. */ + req?: Partial + /** The global slug to validate against. */ + slug: TSlug + /** The user used by access control when `overrideAccess` is `false`. */ + user?: null | User +} & DraftFlagFromGlobalSlug + +type InternalValidateGlobalOptions = { + /** + * Whether `data` stores each localized field as a locale-code-keyed object, as the internal + * publish-all-locales candidate does, rather than a flat, single-locale candidate. + */ + dataIsLocaleKeyed?: boolean + validationDataLocale?: string +} & ValidateGlobalOptions + +export async function validateGlobalLocal( + payload: Payload, + options: ValidateGlobalOptions, +): Promise { + return validateGlobalLocalWithDataLocale(payload, { + slug: options.slug, + context: options.context, + data: options.data, + draft: options.draft, + locale: options.locale, + overrideAccess: options.overrideAccess, + req: options.req, + user: options.user, + }) +} + +export async function validateGlobalLocalWithDataLocale( + payload: Payload, + options: InternalValidateGlobalOptions, +): Promise { + const { + slug, + data, + dataIsLocaleKeyed, + locale, + overrideAccess = false, + validationDataLocale, + } = options + const { draft = false } = options + + if (locale === undefined) { + throw new APIError('Validation requires a locale.', httpStatus.BAD_REQUEST) + } + + const globalConfig = payload.globals.config.find((config) => config.slug === slug) + + if (!globalConfig) { + throw new APIError(`The global with slug ${String(slug)} can't be found. Validate Operation.`) + } + + return runLocaleScopedValidation({ + context: options.context, + data, + fields: globalConfig.fields, + locale, + payload, + req: options.req, + runPass: ({ data: validationData, onValidationData, req }) => + validateOperation({ + slug, + data: validationData, + dataIsLocaleKeyed, + draft, + globalConfig, + onValidationData, + overrideAccess, + req, + }), + user: options.user, + validationDataLocale, + }) +} diff --git a/packages/payload/src/globals/operations/restoreVersion.ts b/packages/payload/src/globals/operations/restoreVersion.ts index 19315b4d1d9..4390a1057ec 100644 --- a/packages/payload/src/globals/operations/restoreVersion.ts +++ b/packages/payload/src/globals/operations/restoreVersion.ts @@ -9,6 +9,7 @@ import { sanitizeWhereQuery } from '../../database/sanitizeWhereQuery.js' import { Forbidden, NotFound } from '../../errors/index.js' import { afterChange } from '../../fields/hooks/afterChange/index.js' import { afterRead } from '../../fields/hooks/afterRead/index.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' @@ -29,6 +30,8 @@ export type Arguments = { export const restoreVersionOperation = async = any>( args: Arguments, ): Promise => { + assertNoValidationWrite(args.req) + const { id, depth, draft, globalConfig, overrideAccess, populate, showHiddenFields } = args const req = args.req! const { fallbackLocale, locale, payload } = req @@ -247,6 +250,7 @@ export const restoreVersionOperation = async = any data: result, doc: result, global: globalConfig, + operation: 'update', overrideAccess, previousDoc, req, diff --git a/packages/payload/src/globals/operations/update.ts b/packages/payload/src/globals/operations/update.ts index f585128d7ff..99274f331ad 100644 --- a/packages/payload/src/globals/operations/update.ts +++ b/packages/payload/src/globals/operations/update.ts @@ -24,6 +24,7 @@ import { afterRead } from '../../fields/hooks/afterRead/index.js' import { beforeChange } from '../../fields/hooks/beforeChange/index.js' import { beforeValidate } from '../../fields/hooks/beforeValidate/index.js' import { deepCopyObjectSimple } from '../../index.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { checkDocumentLockStatus } from '../../utilities/checkDocumentLockStatus.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { getSelectMode } from '../../utilities/getSelectMode.js' @@ -35,6 +36,7 @@ import { } from '../../utilities/getVersionsConfig.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' +import { resolvePublishAllLocales } from '../../utilities/resolvePublishAllLocales.js' import { resolveSelect } from '../../utilities/resolveSelect.js' import { sanitizeSelect } from '../../utilities/sanitizeSelect.js' import { @@ -71,6 +73,8 @@ export const updateOperation = async < >( args: Args, ): Promise> => { + assertNoValidationWrite(args.req) + const req = args.req const initialGlobalConfig = args.globalConfig @@ -79,14 +83,17 @@ export const updateOperation = async < unpublishAllLocales: args.unpublishAllLocales, }) + const initialPublishAllLocales = resolvePublishAllLocales({ + draft: args.draft, + hasLocalizeStatusEnabled: hasLocalizeStatusEnabled(initialGlobalConfig), + locale: req.locale, + publishAllLocalesArg: args.publishAllLocales, + }) const initialAllLocalesPublicationStatus = getAllLocalesPublicationStatus({ hasLocalizedStatus: Boolean( req.payload.config.localization && hasLocalizeStatusEnabled(initialGlobalConfig), ), - publishAllLocales: - !args.draft && - (args.publishAllLocales ?? - !(hasLocalizeStatusEnabled(initialGlobalConfig) && req.locale !== 'all')), + publishAllLocales: initialPublishAllLocales, unpublishAllLocales: Boolean(args.unpublishAllLocales), }) @@ -139,9 +146,12 @@ export const updateOperation = async < unpublishAllLocales: unpublishAllLocalesArg, }) - let publishAllLocales = - !draftArg && - (publishAllLocalesArg ?? !(hasLocalizeStatusEnabled(globalConfig) && locale !== 'all')) + let publishAllLocales = resolvePublishAllLocales({ + draft: draftArg, + hasLocalizeStatusEnabled: hasLocalizeStatusEnabled(globalConfig), + locale, + publishAllLocalesArg, + }) let unpublishAllLocales = typeof unpublishAllLocalesArg === 'string' ? unpublishAllLocalesArg === 'true' @@ -301,6 +311,7 @@ export const updateOperation = async < context: req.context, data, global: globalConfig, + operation: 'update', originalDoc: publicationHookDoc, overrideAccess, req, @@ -319,6 +330,7 @@ export const updateOperation = async < context: req.context, data, global: globalConfig, + operation: 'update', originalDoc: publicationHookDoc, overrideAccess, req, @@ -603,6 +615,7 @@ export const updateOperation = async < data, doc: result, global: globalConfig, + operation: 'update', overrideAccess, previousDoc: originalDoc, req, diff --git a/packages/payload/src/globals/operations/validate.ts b/packages/payload/src/globals/operations/validate.ts new file mode 100644 index 00000000000..6c05c34a7f3 --- /dev/null +++ b/packages/payload/src/globals/operations/validate.ts @@ -0,0 +1,259 @@ +import type { DeepPartial } from 'ts-essentials' + +import type { TypeWithID } from '../../collections/config/types.js' +import type { ValidationResult } from '../../collections/operations/local/validate.js' +import type { AccessResult } from '../../config/types.js' +import type { GlobalSlug, JsonObject } from '../../index.js' +import type { PayloadRequest } from '../../types/index.js' +import type { DataFromGlobalSlug, SanitizedGlobalConfig } from '../config/types.js' + +import { executeAccess } from '../../auth/executeAccess.js' +import { hasWhereAccessResult } from '../../auth/types.js' +import { Forbidden } from '../../errors/index.js' +import { afterRead } from '../../fields/hooks/afterRead/index.js' +import { beforeChange } from '../../fields/hooks/beforeChange/index.js' +import { beforeValidate } from '../../fields/hooks/beforeValidate/index.js' +import { deepCopyObjectSimple } from '../../utilities/deepCopyObject.js' +import { deepMergeWithSourceArraysIgnoringUndefined } from '../../utilities/deepMerge.js' +import { flattenDataByLocale } from '../../utilities/flattenDataByLocale.js' +import { toValidationResult } from '../../utilities/toValidationResult.js' +import { + findDraftVersion, + getDocumentFromDraftVersion, +} from '../../versions/drafts/replaceWithDraftIfAvailable.js' + +export type Arguments = { + data?: DeepPartial, 'id'>> + /** + * Whether `data` stores each localized field as a locale-code-keyed object, as the internal + * publish-all-locales candidate does, rather than a flat, single-locale candidate. + * @default false + */ + dataIsLocaleKeyed?: boolean + draft: boolean + globalConfig: SanitizedGlobalConfig + onValidationData?: (data: JsonObject) => void + overrideAccess: boolean + req: PayloadRequest + slug: string +} + +export async function validateOperation( + args: Arguments, +): Promise { + const previousOperation = args.req.operation + args.req.operation = 'validate' + + try { + return await validateOperationWithScopedRequest(args) + } finally { + args.req.operation = previousOperation + } +} + +async function validateOperationWithScopedRequest({ + slug, + data: incomingData, + dataIsLocaleKeyed = false, + draft, + globalConfig, + onValidationData, + overrideAccess, + req, +}: Arguments): Promise { + const accessResult = !overrideAccess + ? await executeAccess({ slug, data: incomingData, req }, globalConfig.access.validate) + : true + const storedGlobal = await resolveValidationGlobalSource({ + slug, + accessResult, + draft, + globalConfig, + overrideAccess, + req, + }) + + const docWithLocales: JsonObject = deepCopyObjectSimple(storedGlobal) + + if (docWithLocales._id) { + delete docWithLocales._id + } + + const originalDoc = await afterRead({ + collection: null, + context: req.context, + depth: 0, + doc: deepCopyObjectSimple(docWithLocales), + draft, + fallbackLocale: req.fallbackLocale!, + global: globalConfig, + locale: req.locale!, + overrideAccess: true, + req, + showHiddenFields: true, + }) + + let data = flattenDataByLocale({ + configBlockReferences: req.payload.config.blocks, + dataIsLocaleKeyed, + docWithLocales: deepCopyObjectSimple(incomingData ?? {}) as JsonObject, + fields: globalConfig.fields, + locale: req.locale!, + }) + + try { + onValidationData?.(deepMergeWithSourceArraysIgnoringUndefined(originalDoc, data)) + + data = await beforeValidate({ + collection: null, + context: req.context, + data, + doc: originalDoc, + global: globalConfig, + operation: 'validate', + overrideAccess, + req, + }) + onValidationData?.(data) + + if (globalConfig.hooks.beforeValidate?.length) { + for (const hook of globalConfig.hooks.beforeValidate) { + data = + (await hook({ + context: req.context, + data, + global: globalConfig, + operation: 'validate', + originalDoc, + overrideAccess, + req, + })) || data + } + } + + if (globalConfig.hooks.beforeChange?.length) { + for (const hook of globalConfig.hooks.beforeChange) { + data = + (await hook({ + context: req.context, + data, + global: globalConfig, + operation: 'validate', + originalDoc, + overrideAccess, + req, + })) || data + } + } + + onValidationData?.(data) + + await beforeChange({ + collection: null, + context: req.context, + data, + doc: originalDoc, + docWithLocales, + global: globalConfig, + operation: 'validate', + overrideAccess, + req, + }) + } catch (error) { + return toValidationResult({ error, req }) + } + + return { + errors: [], + valid: true, + } +} + +/** + * Selects the newest stored source before it applies a `where` access policy. This prevents an + * older accessible draft from replacing a newer restricted draft. If no draft exists, the main + * global remains the validation source. + */ +async function resolveValidationGlobalSource({ + slug, + accessResult, + draft, + globalConfig, + overrideAccess, + req, +}: { + accessResult: AccessResult + draft: boolean + globalConfig: SanitizedGlobalConfig + overrideAccess: boolean + req: PayloadRequest + slug: string +}): Promise { + const main = await req.payload.db.findGlobal({ + slug, + locale: req.locale!, + req, + }) + const hasMain = hasGlobalSource(main) + const base = (hasMain ? main : { globalType: slug }) as JsonObject & TypeWithID + + if (draft && globalConfig.versions?.drafts) { + const newestDraft = await findDraftVersion({ + accessResult: true, + doc: base, + entity: globalConfig, + entityType: 'global', + overrideAccess: true, + req, + }) + + if (newestDraft) { + if (hasWhereAccessResult(accessResult)) { + const accessibleDraft = await findDraftVersion({ + accessResult, + doc: base, + draftVersionID: newestDraft.id, + entity: globalConfig, + entityType: 'global', + overrideAccess, + req, + }) + + if (!accessibleDraft) { + throw new Forbidden(req.t) + } + } + + return getDocumentFromDraftVersion({ + doc: base, + draftVersion: newestDraft, + entityType: 'global', + }) + } + } + + if (!hasMain) { + return {} + } + + if (!hasWhereAccessResult(accessResult)) { + return main + } + + const accessibleMain = await req.payload.db.findGlobal({ + slug, + locale: req.locale!, + req, + where: accessResult, + }) + + if (!hasGlobalSource(accessibleMain)) { + throw new Forbidden(req.t) + } + + return accessibleMain +} + +function hasGlobalSource(source: JsonObject | null | undefined): source is JsonObject { + return Boolean(source && Object.keys(source).length > 0) +} diff --git a/packages/payload/src/hierarchy/hooks/collectionBeforeChange.ts b/packages/payload/src/hierarchy/hooks/collectionBeforeChange.ts index c774abb7f8a..e4c3bdab080 100644 --- a/packages/payload/src/hierarchy/hooks/collectionBeforeChange.ts +++ b/packages/payload/src/hierarchy/hooks/collectionBeforeChange.ts @@ -29,7 +29,7 @@ export const hierarchyCollectionBeforeChange = const newParentID = data[parentFieldName] !== undefined ? data[parentFieldName] : originalDoc?.[parentFieldName] const parentChanged = - operation === 'update' && + (operation === 'update' || operation === 'validate') && data[parentFieldName] !== undefined && data[parentFieldName] !== originalDoc?.[parentFieldName] diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index 808e3050b90..3ef5d3f1510 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -98,6 +98,11 @@ import { type ManyOptions as UpdateManyOptions, type Options as UpdateOptions, } from './collections/operations/local/update.js' +import { + type ValidateCollectionOptions, + validateLocal, + type ValidationResult, +} from './collections/operations/local/validate.js' import { countGlobalVersionsLocal, type CountGlobalVersionsOptions, @@ -122,6 +127,10 @@ import { updateGlobalLocal, type Options as UpdateGlobalOptions, } from './globals/operations/local/update.js' +import { + validateGlobalLocal, + type ValidateGlobalOptions, +} from './globals/operations/local/validate.js' export type * from './admin/adapters/index.js' export type { FieldState } from './admin/forms/Form.js' export type * from './admin/types.js' @@ -784,6 +793,40 @@ export class BasePayload { return updateGlobalLocal(this, options) } + /** + * Validates a collection document candidate for selected locales without persisting data, + * versions, or files. + * + * Omit `id` to validate create data. With `id`, the stored main document is the default base; + * `draft: true` selects the newest available draft version and falls back to the main document. + * Partial candidate data is merged over that base. Field validation failures resolve to + * `{ valid: false, errors }`; access, argument, lookup, and other lifecycle errors throw. + * + * @see https://payloadcms.com/docs/validation/overview#local-api + */ + validate = async ( + options: ValidateCollectionOptions, + ): Promise => { + return validateLocal(this, options) + } + + /** + * Validates a global document candidate for selected locales without persisting data or + * versions. + * + * The stored main global is the default base; `draft: true` selects the newest available draft + * version and falls back to the main global. Partial candidate data is merged over that base. + * Field validation failures resolve to `{ valid: false, errors }`; access, argument, and other + * lifecycle errors throw. + * + * @see https://payloadcms.com/docs/validation/overview#local-api + */ + validateGlobal = async ( + options: ValidateGlobalOptions, + ): Promise => { + return validateGlobalLocal(this, options) + } + validationRules!: (args: OperationArgs) => ValidationRule[] verifyEmail = async ( @@ -1543,6 +1586,10 @@ export { updateDocumentInputSchema, updateDocumentLocalInputSchema, } from './collections/operations/inputSchemas.js' +export type { + ValidateCollectionOptions, + ValidationResult, +} from './collections/operations/local/validate.js' export { restoreVersionOperation } from './collections/operations/restoreVersion.js' export { updateOperation } from './collections/operations/update.js' export { updateByIDOperation } from './collections/operations/updateByID.js' @@ -1903,7 +1950,6 @@ export type { } from './globals/config/types.js' export { docAccessOperation as docAccessOperationGlobal } from './globals/operations/docAccess.js' export { findOneOperation } from './globals/operations/findOne.js' - export { findVersionByIDOperation as findVersionByIDOperationGlobal } from './globals/operations/findVersionByID.js' export { findVersionsOperation as findVersionsOperationGlobal } from './globals/operations/findVersions.js' export { @@ -1921,6 +1967,7 @@ export { updateGlobalInputSchema, updateGlobalLocalInputSchema, } from './globals/operations/inputSchemas.js' +export type { ValidateGlobalOptions } from './globals/operations/local/validate.js' export { restoreVersionOperation as restoreVersionOperationGlobal } from './globals/operations/restoreVersion.js' export { updateOperation as updateOperationGlobal } from './globals/operations/update.js' export { diff --git a/packages/payload/src/query-presets/access.ts b/packages/payload/src/query-presets/access.ts index a8520596e79..15bb7ff2995 100644 --- a/packages/payload/src/query-presets/access.ts +++ b/packages/payload/src/query-presets/access.ts @@ -4,7 +4,9 @@ import type { Operation } from '../types/index.js' import { defaultAccess } from '../auth/defaultAccess.js' import { defaultUnlockAccess } from '../auth/defaultUnlockAccess.js' -const operations: Operation[] = ['delete', 'read', 'update', 'create'] as const +type QueryPresetOperation = Exclude + +const operations: QueryPresetOperation[] = ['delete', 'read', 'update', 'create'] as const const defaultCollectionAccess = { create: defaultAccess, @@ -14,7 +16,7 @@ const defaultCollectionAccess = { update: defaultAccess, } -export const getAccess = (config: Config): Record => +export const getAccess = (config: Config): Record => operations.reduce( (acc, operation) => { acc[operation] = async (args) => { @@ -101,5 +103,5 @@ export const getAccess = (config: Config): Record => return acc }, - {} as Record, + {} as Record, ) diff --git a/packages/payload/src/query-presets/config.ts b/packages/payload/src/query-presets/config.ts index 6af8cd6edec..1800b57c23b 100644 --- a/packages/payload/src/query-presets/config.ts +++ b/packages/payload/src/query-presets/config.ts @@ -181,7 +181,7 @@ export const getQueryPresetsConfig = (config: Config): CollectionConfig => ({ // TODO: type this const typedData = data as any - if (operation === 'create' || operation === 'update') { + if (operation === 'create' || operation === 'update' || operation === 'validate') { // Ensure all operations have a constraint operations.forEach((operation) => { if (!typedData.access) { diff --git a/packages/payload/src/queues/localAPI.ts b/packages/payload/src/queues/localAPI.ts index 33b32650caa..3c297b7aa8a 100644 --- a/packages/payload/src/queues/localAPI.ts +++ b/packages/payload/src/queues/localAPI.ts @@ -11,6 +11,7 @@ import { type TypedJobs, type Where, } from '../index.js' +import { assertNoValidationWrite } from '../utilities/assertNoValidationWrite.js' import { jobAfterRead, jobsCollectionSlug } from './config/collection.js' import { handleSchedules, type HandleSchedulesResult } from './operations/handleSchedules/index.js' import { runJobs } from './operations/runJobs/index.js' @@ -44,6 +45,8 @@ export const getJobsLocalAPI = (payload: Payload) => ({ }): Promise => { const newReq: PayloadRequest = args?.req ?? (await createPayloadRequest({ payload })) + assertNoValidationWrite(newReq) + return await handleSchedules({ allQueues: args?.allQueues, queue: args?.queue, @@ -97,6 +100,8 @@ export const getJobsLocalAPI = (payload: Payload) => ({ const overrideAccess = args.overrideAccess ?? false const req: PayloadRequest = args.req ?? (await createPayloadRequest({ payload })) + assertNoValidationWrite(req) + if (!overrideAccess) { /** * By default, jobsConfig.access.queue will be `defaultAccess` which is a function that returns `true` if the user is logged in. diff --git a/packages/payload/src/queues/utilities/updateJob.ts b/packages/payload/src/queues/utilities/updateJob.ts index 4e8da873814..616d70f2754 100644 --- a/packages/payload/src/queues/utilities/updateJob.ts +++ b/packages/payload/src/queues/utilities/updateJob.ts @@ -2,6 +2,7 @@ import type { UpdateJobsArgs } from '../../database/types.js' import type { Job } from '../../index.js' import type { PayloadRequest, Sort, Where } from '../../types/index.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { jobAfterRead } from '../config/collection.js' import { getCurrentDate } from './getCurrentDate.js' @@ -52,6 +53,8 @@ export async function updateJobs({ sort, where: whereArg, }: RunJobsArgs): Promise { + assertNoValidationWrite(req) + const limit = id ? 1 : limitArg const where = id ? { id: { equals: id } } : whereArg diff --git a/packages/payload/src/types/index.ts b/packages/payload/src/types/index.ts index 946814d6d5d..f5a7e568968 100644 --- a/packages/payload/src/types/index.ts +++ b/packages/payload/src/types/index.ts @@ -130,6 +130,8 @@ export interface PayloadRequest Partial, PayloadRequestData { headers: Request['headers'] + /** The active Payload operation. */ + operation?: FieldOperation } export type { HasManyRelationshipOperator, Operator } @@ -190,7 +192,14 @@ export type JoinQuery = // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Document = any -export type Operation = 'create' | 'delete' | 'read' | 'update' +/** + * Operations exposed to access control and request lifecycle functions. + * + * On-demand validation uses the first-class `validate` value rather than substituting `create` + * or `update`. + */ +export type Operation = 'create' | 'delete' | 'read' | 'update' | 'validate' +export type FieldOperation = Operation export type VersionOperations = 'readVersions' export type AuthOperations = 'unlock' export type AllOperations = AuthOperations | Operation | VersionOperations diff --git a/packages/payload/src/uploads/uploadFiles.ts b/packages/payload/src/uploads/uploadFiles.ts index 4e8cc038fb9..3426bef0e64 100644 --- a/packages/payload/src/uploads/uploadFiles.ts +++ b/packages/payload/src/uploads/uploadFiles.ts @@ -5,6 +5,7 @@ import type { PayloadRequest } from '../types/index.js' import type { FileToSave } from './types.js' import { FileUploadError } from '../errors/index.js' +import { assertNoValidationWrite } from '../utilities/assertNoValidationWrite.js' import { saveBufferToFile } from './saveBufferToFile.js' export const uploadFiles = async ( @@ -12,6 +13,8 @@ export const uploadFiles = async ( files: FileToSave[], req: PayloadRequest, ): Promise => { + assertNoValidationWrite(req) + try { await Promise.all( files.map(async (file) => { diff --git a/packages/payload/src/utilities/assertNoValidationWrite.ts b/packages/payload/src/utilities/assertNoValidationWrite.ts new file mode 100644 index 00000000000..3d55842ba1d --- /dev/null +++ b/packages/payload/src/utilities/assertNoValidationWrite.ts @@ -0,0 +1,14 @@ +import type { PayloadRequest } from '../types/index.js' + +import { APIError } from '../errors/index.js' + +/** + * Prevents guarded document, global, upload, and version mutation entry points from writing + * through an active validation request. Reads and writes made with a separate request are + * intentionally unaffected. + */ +export function assertNoValidationWrite(req?: Partial): void { + if (req?.operation === 'validate') { + throw new APIError('Payload writes are not allowed during validation.') + } +} diff --git a/packages/payload/src/utilities/createPayloadRequest.ts b/packages/payload/src/utilities/createPayloadRequest.ts index 7b972434ce8..930013bfeb2 100644 --- a/packages/payload/src/utilities/createPayloadRequest.ts +++ b/packages/payload/src/utilities/createPayloadRequest.ts @@ -93,7 +93,7 @@ export type CreatePayloadRequestArgs = { payload: Payload req?: Partial urlSuffix?: string - user?: User + user?: null | User } type CreatePayloadRequest = (args: CreatePayloadRequestArgs) => Promise @@ -140,7 +140,7 @@ export const createPayloadRequest: CreatePayloadRequest = async ({ req.payload = payload req.i18n = i18n req.t = i18n.t - req.user = user || req?.user || null + req.user = user === undefined ? (req?.user ?? null) : user // Ensure user.collection is set for auth-related access control // TODO (4.0): Instead of silently falling back, throw an error if user.collection is missing diff --git a/packages/payload/src/utilities/deepMerge.ts b/packages/payload/src/utilities/deepMerge.ts index bdf01a45a76..0f3c8193f0f 100644 --- a/packages/payload/src/utilities/deepMerge.ts +++ b/packages/payload/src/utilities/deepMerge.ts @@ -41,6 +41,38 @@ export function deepMergeWithSourceArrays(obj1: object, obj2: return deepMerge(obj1, obj2, { arrayMerge: (_, source) => source }) } +/** + * Deep-merges objects while replacing arrays and retaining target values when the source contains + * `undefined`. This matches partial update semantics, where `undefined` means that a stored value + * was omitted rather than cleared. + */ +export function deepMergeWithSourceArraysIgnoringUndefined( + obj1: object, + obj2: object, +): T { + return deepMergeWithSourceArrays(obj1, removeUndefinedProperties(obj2)) +} + +function removeUndefinedProperties(value: T): T { + if (Array.isArray(value)) { + return value.map(removeUndefinedProperties) as T + } + + if ( + value === null || + typeof value !== 'object' || + (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) + ) { + return value + } + + return Object.fromEntries( + Object.entries(value) + .filter(([, nestedValue]) => nestedValue !== undefined) + .map(([key, nestedValue]) => [key, removeUndefinedProperties(nestedValue)]), + ) as T +} + /** * Fully-featured deepMerge. Does not clone React components by default. */ diff --git a/packages/payload/src/utilities/entityInputSchema/filterFieldsByAccess.ts b/packages/payload/src/utilities/entityInputSchema/filterFieldsByAccess.ts index f185263ffed..237cd7e0600 100644 --- a/packages/payload/src/utilities/entityInputSchema/filterFieldsByAccess.ts +++ b/packages/payload/src/utilities/entityInputSchema/filterFieldsByAccess.ts @@ -49,6 +49,7 @@ export const filterFieldsByAccess = ({ create: isOperationAllowed('create'), read: isOperationAllowed('read'), update: isOperationAllowed('update'), + validate: isOperationAllowed('validate'), } if (shouldExcludeField(allowedOperations)) { @@ -115,5 +116,6 @@ export const filterFieldsByAccess = ({ create: 'create' in permissions && permissions.create === true, read: permissions.read === true, update: permissions.update === true, + validate: permissions.validate === true, }) } diff --git a/packages/payload/src/utilities/flattenDataByLocale.ts b/packages/payload/src/utilities/flattenDataByLocale.ts new file mode 100644 index 00000000000..263238253c0 --- /dev/null +++ b/packages/payload/src/utilities/flattenDataByLocale.ts @@ -0,0 +1,247 @@ +import type { Block, Field, FlattenedBlock } from '../fields/config/types.js' +import type { SanitizedConfig } from '../index.js' +import type { JsonObject } from '../types/index.js' + +import { fieldAffectsData, fieldShouldBeLocalized, tabHasName } from '../fields/config/types.js' +import { deepCopyObjectSimple } from './deepCopyObject.js' + +type Args = { + configBlockReferences: SanitizedConfig['blocks'] + /** + * Whether `docWithLocales` stores each localized field as a locale-code-keyed object (the + * stored document representation, and the internal publish-all-locales candidate). Pass + * `false` for a flat, single-locale candidate, such as the data passed to `payload.validate()`. + * @default true + */ + dataIsLocaleKeyed?: boolean + docWithLocales: JsonObject + fields: Field[] + locale: string + parentIsLocalized?: boolean +} + +/** + * Returns a copy of locale-keyed data flattened to one locale and converts field storage + * representations needed by validators, without running after-read hooks, access control, + * sanitization, or population. + */ +export function flattenDataByLocale({ + configBlockReferences, + dataIsLocaleKeyed = true, + docWithLocales, + fields, + locale, + parentIsLocalized = false, +}: Args): JsonObject { + const result = deepCopyObjectSimple(docWithLocales) + + flattenFields({ + configBlockReferences, + data: result, + dataIsLocaleKeyed, + fields, + locale, + parentIsLocalized, + }) + + return result +} + +type FlattenFieldsArgs = { + configBlockReferences: SanitizedConfig['blocks'] + data: JsonObject + dataIsLocaleKeyed: boolean + fields: Field[] + locale: string + parentIsLocalized: boolean +} + +function flattenFields({ + configBlockReferences, + data, + dataIsLocaleKeyed, + fields, + locale, + parentIsLocalized, +}: FlattenFieldsArgs): void { + for (const field of fields) { + if (fieldAffectsData(field)) { + const isLocalized = fieldShouldBeLocalized({ field, parentIsLocalized }) + + if (isLocalized) { + data[field.name] = getLocaleValue({ + dataIsLocaleKeyed, + locale, + value: data[field.name], + }) + } + + data[field.name] = transformStoredFieldValue({ + field, + value: data[field.name], + }) + + const fieldValue = data[field.name] + const nestedParentIsLocalized = parentIsLocalized || Boolean(field.localized) + + switch (field.type) { + case 'array': { + if (Array.isArray(fieldValue)) { + for (const row of fieldValue) { + if (row && typeof row === 'object') { + flattenFields({ + configBlockReferences, + data: row, + dataIsLocaleKeyed, + fields: field.fields, + locale, + parentIsLocalized: nestedParentIsLocalized, + }) + } + } + } + break + } + + case 'blocks': { + if (Array.isArray(fieldValue)) { + for (const row of fieldValue) { + if (!row || typeof row !== 'object') { + continue + } + + const blockOrSlug = field.blocks.find((block) => { + const blockSlug = typeof block === 'string' ? block : block.slug + return blockSlug === row.blockType + }) + const block: Block | FlattenedBlock | undefined = + typeof blockOrSlug === 'string' + ? configBlockReferences?.find(({ slug }) => slug === blockOrSlug) + : blockOrSlug + + if (block) { + flattenFields({ + configBlockReferences, + data: row, + dataIsLocaleKeyed, + fields: block.fields, + locale, + parentIsLocalized: nestedParentIsLocalized, + }) + } + } + } + break + } + + case 'group': { + if (fieldValue && typeof fieldValue === 'object' && !Array.isArray(fieldValue)) { + flattenFields({ + configBlockReferences, + data: fieldValue, + dataIsLocaleKeyed, + fields: field.fields, + locale, + parentIsLocalized: nestedParentIsLocalized, + }) + } + break + } + } + + continue + } + + switch (field.type) { + case 'collapsible': + case 'group': + case 'row': + flattenFields({ + configBlockReferences, + data, + dataIsLocaleKeyed, + fields: field.fields, + locale, + parentIsLocalized, + }) + break + + case 'tabs': + for (const tab of field.tabs) { + if (tabHasName(tab)) { + const isLocalized = fieldShouldBeLocalized({ field: tab, parentIsLocalized }) + + if (isLocalized) { + data[tab.name] = getLocaleValue({ + dataIsLocaleKeyed, + locale, + value: data[tab.name], + }) + } + + const tabData = data[tab.name] + + if (tabData && typeof tabData === 'object' && !Array.isArray(tabData)) { + flattenFields({ + configBlockReferences, + data: tabData, + dataIsLocaleKeyed, + fields: tab.fields, + locale, + parentIsLocalized: parentIsLocalized || Boolean(tab.localized), + }) + } + } else { + flattenFields({ + configBlockReferences, + data, + dataIsLocaleKeyed, + fields: tab.fields, + locale, + parentIsLocalized, + }) + } + } + break + } + } +} + +function transformStoredFieldValue({ field, value }: { field: Field; value: unknown }): unknown { + switch (field.type) { + case 'point': { + if (Array.isArray(value)) { + return value + } + + if (value && typeof value === 'object') { + const coordinates = (value as Record).coordinates + + if (Array.isArray(coordinates) && coordinates.length === 2) { + return coordinates + } + } + + return value + } + + default: + return value + } +} + +function getLocaleValue({ + dataIsLocaleKeyed, + locale, + value, +}: { + dataIsLocaleKeyed: boolean + locale: string + value: unknown +}): unknown { + if (dataIsLocaleKeyed && value && typeof value === 'object' && !Array.isArray(value)) { + return (value as Record)[locale] + } + + return value +} diff --git a/packages/payload/src/utilities/getAccessOperationRequest.ts b/packages/payload/src/utilities/getAccessOperationRequest.ts new file mode 100644 index 00000000000..325d94a6fb2 --- /dev/null +++ b/packages/payload/src/utilities/getAccessOperationRequest.ts @@ -0,0 +1,21 @@ +import type { FieldOperation, PayloadRequest } from '../types/index.js' + +import { isolateObjectProperty } from './isolateObjectProperty.js' + +/** + * Returns a request view whose active operation is isolated from the caller and sibling access + * policies. Runtime services and transaction identity remain shared with the original request. + */ +export function getAccessOperationRequest({ + operation, + req, +}: { + operation: FieldOperation + req: PayloadRequest +}): PayloadRequest { + const operationRequest = isolateObjectProperty(req, 'operation') + + operationRequest.operation = operation + + return operationRequest +} diff --git a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts index 672191afa01..e4c178ca9d9 100644 --- a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts +++ b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts @@ -12,6 +12,7 @@ import type { SanitizedGlobalConfig } from '../../globals/config/types.js' import type { BlockSlug, DefaultDocumentIDType } from '../../index.js' import type { AllOperations, JsonObject, PayloadRequest, Where } from '../../types/index.js' +import { getAccessOperationRequest } from '../getAccessOperationRequest.js' import { entityDocExists } from './entityDocExists.js' import { populateFieldPermissions } from './populateFieldPermissions.js' @@ -58,9 +59,10 @@ const topLevelCollectionPermissions = [ 'read', 'readVersions', 'update', + 'validate', 'unlock', ] -const topLevelGlobalPermissions = ['read', 'readVersions', 'update'] +const topLevelGlobalPermissions = ['read', 'readVersions', 'update', 'validate'] /** * Build up permissions object for an entity (collection or global). @@ -162,9 +164,20 @@ export async function getEntityPermissions, + result: Promise.resolve( + accessFunction({ + id, + slug: entity.slug, + data, + req: + _operation === 'readVersions' || _operation === 'unlock' + ? req + : getAccessOperationRequest({ + operation: _operation, + req, + }), + }), + ) as Promise, }) } else { entityPermissions[operation] = { diff --git a/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts b/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts index c72e37e2f71..c927ca5513d 100644 --- a/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts +++ b/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts @@ -14,6 +14,7 @@ import type { AllOperations, JsonObject, PayloadRequest } from '../../types/inde import type { BlockReferencesPermissions } from './getEntityPermissions.js' import { type Field, tabHasName } from '../../fields/config/types.js' +import { getAccessOperationRequest } from '../getAccessOperationRequest.js' const isThenable = (value: unknown): value is Promise => value != null && typeof (value as { then?: unknown }).then === 'function' @@ -107,7 +108,10 @@ export const populateFieldPermissions = ({ collection, data, doc: data, - req, + req: getAccessOperationRequest({ + operation, + req, + }), // We cannot include siblingData or blockData here, as we do not have siblingData/blockData available once we reach block or array // rows, as we're calculating schema permissions, which do not include individual rows. // For consistency, it's thus better to never include the siblingData and blockData @@ -117,7 +121,10 @@ export const populateFieldPermissions = ({ data, doc: data, global: global!, - req, + req: getAccessOperationRequest({ + operation, + req, + }), // We cannot include siblingData or blockData here, as we do not have siblingData/blockData available once we reach block or array // rows, as we're calculating schema permissions, which do not include individual rows. // For consistency, it's thus better to never include the siblingData and blockData diff --git a/packages/payload/src/utilities/getFieldPermissions.ts b/packages/payload/src/utilities/getFieldPermissions.ts index fca4fe39294..07f442b2618 100644 --- a/packages/payload/src/utilities/getFieldPermissions.ts +++ b/packages/payload/src/utilities/getFieldPermissions.ts @@ -10,7 +10,7 @@ import type { Operation } from '../types/index.js' /** * Gets read and operation-level permissions for a given field based on cascading field permissions. * @returns An object with the following properties: - * - `operation`: Whether the user has permission to perform the operation on the field (`create` or `update`). + * - `operation`: Whether the user has permission to perform the operation on the field. * - `permissions`: The field-level permissions. * - `read`: Whether the user has permission to read the field. */ @@ -79,6 +79,8 @@ export const getFieldPermissions = ({ collectionOperation = Boolean(collectionPermissions.create) } else if (operation === 'update') { collectionOperation = Boolean(collectionPermissions.update) + } else if (operation === 'validate') { + collectionOperation = Boolean(collectionPermissions.validate) } return { diff --git a/packages/payload/src/utilities/isValidationErrorPathLocalized.ts b/packages/payload/src/utilities/isValidationErrorPathLocalized.ts new file mode 100644 index 00000000000..f65f32df3a8 --- /dev/null +++ b/packages/payload/src/utilities/isValidationErrorPathLocalized.ts @@ -0,0 +1,214 @@ +import type { Block, Field, FlattenedBlock } from '../fields/config/types.js' +import type { SanitizedConfig } from '../index.js' +import type { JsonObject } from '../types/index.js' + +import { fieldAffectsData, fieldShouldBeLocalized, tabHasName } from '../fields/config/types.js' + +type IsValidationErrorPathLocalizedArgs = { + configBlockReferences: SanitizedConfig['blocks'] + data: JsonObject + fields: Field[] + path: string +} + +/** + * Reports whether a `ValidationFieldError.path` refers to a localized field, by walking `fields` + * and `data` together the same way `projectNonLocalizedData` does. Used to tell apart a candidate + * error that's inherently per-locale from one for a shared, non-localized field that every locale + * pass in `runLocaleScopedValidation` re-validates identically. A path that can't be resolved + * (unknown field, malformed path) is conservatively treated as localized, since wrongly collapsing + * a genuinely per-locale error into one is worse than leaving an occasional duplicate. + */ +export function isValidationErrorPathLocalized({ + configBlockReferences, + data, + fields, + path, +}: IsValidationErrorPathLocalizedArgs): boolean { + return ( + resolvePathLocalization({ + configBlockReferences, + data, + fields, + parentIsLocalized: false, + segments: path.split('.'), + }) ?? true + ) +} + +type ResolvePathLocalizationArgs = { + configBlockReferences: SanitizedConfig['blocks'] + data: unknown + fields: Field[] + parentIsLocalized: boolean + segments: string[] +} + +/** + * Returns `undefined` when `segments[0]` matches no field at this level, so the caller can keep + * searching sibling fields (relevant for `row`/`collapsible`/unnamed `tabs`, which don't consume a + * path segment themselves). + */ +function resolvePathLocalization({ + configBlockReferences, + data, + fields, + parentIsLocalized, + segments, +}: ResolvePathLocalizationArgs): boolean | undefined { + const [segment, ...remainingSegments] = segments + + if (segment === undefined) { + return parentIsLocalized + } + + if (/^\d+$/.test(segment) && Array.isArray(data)) { + return resolvePathLocalization({ + configBlockReferences, + data: data[Number(segment)], + fields, + parentIsLocalized, + segments: remainingSegments, + }) + } + + for (const field of fields) { + if (fieldAffectsData(field)) { + if (field.name !== segment) { + continue + } + + const isLocalized = parentIsLocalized || fieldShouldBeLocalized({ field, parentIsLocalized }) + + if (remainingSegments.length === 0) { + return isLocalized + } + + const fieldValue = isObject(data) ? data[segment] : undefined + + switch (field.type) { + case 'array': + case 'group': + return ( + resolvePathLocalization({ + configBlockReferences, + data: fieldValue, + fields: field.fields, + parentIsLocalized: isLocalized, + segments: remainingSegments, + }) ?? isLocalized + ) + + case 'blocks': { + if (!Array.isArray(fieldValue)) { + return isLocalized + } + + const [rowSegment, ...afterRow] = remainingSegments + const row = + rowSegment && /^\d+$/.test(rowSegment) ? fieldValue[Number(rowSegment)] : undefined + + if (!isObject(row)) { + return isLocalized + } + + const blockOrSlug = field.blocks.find((block) => { + const slug = typeof block === 'string' ? block : block.slug + return slug === row.blockType + }) + const block: Block | FlattenedBlock | undefined = + typeof blockOrSlug === 'string' + ? configBlockReferences?.find(({ slug }) => slug === blockOrSlug) + : blockOrSlug + + if (!block) { + return isLocalized + } + + return ( + resolvePathLocalization({ + configBlockReferences, + data: row, + fields: block.fields, + parentIsLocalized: isLocalized, + segments: afterRow, + }) ?? isLocalized + ) + } + + default: + return isLocalized + } + } + + switch (field.type) { + case 'collapsible': + case 'row': { + const nested = resolvePathLocalization({ + configBlockReferences, + data, + fields: field.fields, + parentIsLocalized, + segments, + }) + + if (nested !== undefined) { + return nested + } + + continue + } + + case 'tabs': { + for (const tab of field.tabs) { + if (!tabHasName(tab)) { + const nested = resolvePathLocalization({ + configBlockReferences, + data, + fields: tab.fields, + parentIsLocalized, + segments, + }) + + if (nested !== undefined) { + return nested + } + + continue + } + + if (tab.name !== segment) { + continue + } + + const isLocalized = + parentIsLocalized || fieldShouldBeLocalized({ field: tab, parentIsLocalized }) + + if (remainingSegments.length === 0) { + return isLocalized + } + + const tabValue = isObject(data) ? data[segment] : undefined + + return ( + resolvePathLocalization({ + configBlockReferences, + data: tabValue, + fields: tab.fields, + parentIsLocalized: isLocalized, + segments: remainingSegments, + }) ?? isLocalized + ) + } + + continue + } + } + } + + return undefined +} + +function isObject(value: unknown): value is JsonObject { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} diff --git a/packages/payload/src/utilities/parseValidationLocale.ts b/packages/payload/src/utilities/parseValidationLocale.ts new file mode 100644 index 00000000000..32b0e296302 --- /dev/null +++ b/packages/payload/src/utilities/parseValidationLocale.ts @@ -0,0 +1,79 @@ +import { status as httpStatus } from 'http-status' + +import type { TypedLocale } from '../index.js' +import type { ValidationLocaleSelector } from './resolveValidationLocales.js' + +import { APIError } from '../errors/index.js' + +type ValidationLocale = + | { + locale: TypedLocale + type: 'single' + } + | { + locales: TypedLocale[] + type: 'multiple' + } + | { + type: 'all' + } + +export function parseValidationLocale(locale: unknown): ValidationLocale { + if (typeof locale === 'string') { + if (locale.length === 0) { + throw new APIError('Validation requires a locale.', httpStatus.BAD_REQUEST) + } + + if (locale === 'all') { + return { type: 'all' } + } + + return { + type: 'single', + locale: locale as TypedLocale, + } + } + + if ( + Array.isArray(locale) && + locale.length > 0 && + locale.every((value) => typeof value === 'string') + ) { + if (locale.some((value) => value.length === 0)) { + throw new APIError('Validation requires a locale.', httpStatus.BAD_REQUEST) + } + + return { + type: 'multiple', + locales: locale as TypedLocale[], + } + } + + throw new APIError('Validation requires a locale.', httpStatus.BAD_REQUEST) +} + +/** + * Parses a REST `locale` query value. Repeated query parameters are represented as an array and + * `locale=all` selects all locales. + */ +export function parseValidationLocaleSelector(locale: unknown): ValidationLocaleSelector { + const parsedLocale = parseValidationLocale(locale) + + switch (parsedLocale.type) { + case 'all': + return 'all' + + case 'multiple': + return parsedLocale.locales as [TypedLocale, ...TypedLocale[]] + + case 'single': + return parsedLocale.locale + } +} + +/** Ensures a REST validation request body is a non-null JSON object. */ +export function assertValidationData(data: unknown): asserts data is Record { + if (!data || Array.isArray(data) || typeof data !== 'object') { + throw new APIError('Validation data must be an object.', httpStatus.BAD_REQUEST) + } +} diff --git a/packages/payload/src/utilities/projectNonLocalizedData.ts b/packages/payload/src/utilities/projectNonLocalizedData.ts new file mode 100644 index 00000000000..bfa15ab256c --- /dev/null +++ b/packages/payload/src/utilities/projectNonLocalizedData.ts @@ -0,0 +1,153 @@ +import type { Block, Field, FlattenedBlock } from '../fields/config/types.js' +import type { SanitizedConfig } from '../index.js' +import type { JsonObject } from '../types/index.js' + +import { fieldAffectsData, fieldShouldBeLocalized, tabHasName } from '../fields/config/types.js' +import { deepCopyObjectSimple } from './deepCopyObject.js' + +type ProjectNonLocalizedDataArgs = { + configBlockReferences: SanitizedConfig['blocks'] + data: JsonObject + fields: Field[] +} + +export function projectNonLocalizedData({ + configBlockReferences, + data, + fields, +}: ProjectNonLocalizedDataArgs): JsonObject { + const projectedData = deepCopyObjectSimple(data) + + removeLocalizedData({ + configBlockReferences, + data: projectedData, + fields, + parentIsLocalized: false, + }) + + return projectedData +} + +type RemoveLocalizedDataArgs = { + parentIsLocalized: boolean +} & ProjectNonLocalizedDataArgs + +function removeLocalizedData({ + configBlockReferences, + data, + fields, + parentIsLocalized, +}: RemoveLocalizedDataArgs): void { + for (const field of fields) { + if (fieldAffectsData(field)) { + if (parentIsLocalized || fieldShouldBeLocalized({ field, parentIsLocalized })) { + delete data[field.name] + continue + } + + const fieldValue = data[field.name] + + switch (field.type) { + case 'array': { + if (Array.isArray(fieldValue)) { + for (const row of fieldValue) { + if (isObject(row)) { + removeLocalizedData({ + configBlockReferences, + data: row, + fields: field.fields, + parentIsLocalized: false, + }) + } + } + } + break + } + + case 'blocks': { + if (Array.isArray(fieldValue)) { + for (const row of fieldValue) { + if (!isObject(row)) { + continue + } + + const blockOrSlug = field.blocks.find((block) => { + const slug = typeof block === 'string' ? block : block.slug + return slug === row.blockType + }) + const block: Block | FlattenedBlock | undefined = + typeof blockOrSlug === 'string' + ? configBlockReferences?.find(({ slug }) => slug === blockOrSlug) + : blockOrSlug + + if (block) { + removeLocalizedData({ + configBlockReferences, + data: row, + fields: block.fields, + parentIsLocalized: false, + }) + } + } + } + break + } + + case 'group': { + if (isObject(fieldValue)) { + removeLocalizedData({ + configBlockReferences, + data: fieldValue, + fields: field.fields, + parentIsLocalized: false, + }) + } + break + } + } + } else { + switch (field.type) { + case 'collapsible': + case 'group': + case 'row': { + removeLocalizedData({ + configBlockReferences, + data, + fields: field.fields, + parentIsLocalized, + }) + break + } + + case 'tabs': { + for (const tab of field.tabs) { + if (tabHasName(tab)) { + if (parentIsLocalized || fieldShouldBeLocalized({ field: tab, parentIsLocalized })) { + delete data[tab.name] + } else if (isObject(data[tab.name])) { + removeLocalizedData({ + configBlockReferences, + data: data[tab.name], + fields: tab.fields, + parentIsLocalized: false, + }) + } + } else { + removeLocalizedData({ + configBlockReferences, + data, + fields: tab.fields, + parentIsLocalized, + }) + } + } + break + } + } + } + } +} + +function isObject(value: unknown): value is JsonObject { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} diff --git a/packages/payload/src/utilities/resolvePublishAllLocales.ts b/packages/payload/src/utilities/resolvePublishAllLocales.ts new file mode 100644 index 00000000000..0d884c79f48 --- /dev/null +++ b/packages/payload/src/utilities/resolvePublishAllLocales.ts @@ -0,0 +1,19 @@ +/** + * Draft saves never publish all locales. Otherwise, saves publish all locales unless localize + * status is enabled for one specific locale or the caller explicitly disables it. Shared by + * create, and the collection and global update operations, so the formula has one owner instead + * of three copies that can drift apart. + */ +export function resolvePublishAllLocales({ + draft, + hasLocalizeStatusEnabled, + locale, + publishAllLocalesArg, +}: { + draft: boolean | undefined + hasLocalizeStatusEnabled: boolean + locale?: null | string + publishAllLocalesArg: boolean | undefined +}): boolean { + return !draft && (publishAllLocalesArg ?? !(hasLocalizeStatusEnabled && locale !== 'all')) +} diff --git a/packages/payload/src/utilities/resolveValidationLocales.ts b/packages/payload/src/utilities/resolveValidationLocales.ts new file mode 100644 index 00000000000..36fc392c0ca --- /dev/null +++ b/packages/payload/src/utilities/resolveValidationLocales.ts @@ -0,0 +1,295 @@ +import { status as httpStatus } from 'http-status' + +import type { TypedLocale } from '../index.js' +import type { PayloadRequest } from '../types/index.js' + +import { APIError } from '../errors/index.js' + +// TypedLocale is narrowed by generated types, while its untyped fallback intentionally includes string. +/** + * Locales accepted by collection and global on-demand validation. + * + * A non-empty array validates its unique locale codes in the order provided. `'all'` validates + * every locale available to the request. Projects without localization use `null` in the Local + * API or `locale=all` in the REST API. + */ +/* eslint-disable @typescript-eslint/no-redundant-type-constituents */ +export type ValidationLocaleSelector = + | 'all' + | readonly [TypedLocale, ...TypedLocale[]] + | TypedLocale +/* eslint-enable @typescript-eslint/no-redundant-type-constituents */ + +const validationLocaleConcurrency = 3 +const sharedValidationRequestProperties = new Set([ + 'i18n', + 'payload', + 'server', + 'signal', + 't', + 'transactionID', +]) + +export async function resolveValidationLocales({ + locale, + req, +}: { + locale: ValidationLocaleSelector + req: PayloadRequest +}): Promise { + const localization = req.payload.config.localization + + if (!localization) { + if (locale === 'all') { + return [null] as TypedLocale[] + } + + const locales = Array.isArray(locale) ? locale : [locale] + + if (locales.length === 0 || locales.some((value) => value !== null)) { + throw new APIError('Validation requires a locale.', httpStatus.BAD_REQUEST) + } + + return [...new Set(locales)] + } + + let availableLocaleCodes = localization.localeCodes + + if (localization.filterAvailableLocales) { + const availableLocales = await localization.filterAvailableLocales({ + locales: localization.locales, + req, + }) + availableLocaleCodes = availableLocales.map((availableLocale) => + typeof availableLocale === 'string' ? availableLocale : availableLocale.code, + ) + } + + if (locale === 'all') { + if (availableLocaleCodes.length === 0) { + throw new APIError('No validation locales are available.', httpStatus.BAD_REQUEST) + } + + return [...new Set(availableLocaleCodes)] as TypedLocale[] + } + + const requestedLocales = Array.isArray(locale) ? locale : [locale] + + if ( + requestedLocales.length === 0 || + requestedLocales.some( + (requestedLocale) => typeof requestedLocale !== 'string' || requestedLocale.length === 0, + ) + ) { + throw new APIError('Validation requires a locale.', httpStatus.BAD_REQUEST) + } + + const locales = [...new Set(requestedLocales)] + + for (const requestedLocale of locales) { + if (!localization.localeCodes.includes(requestedLocale as string)) { + throw new APIError( + `Validation locale "${String(requestedLocale)}" is not configured.`, + httpStatus.BAD_REQUEST, + ) + } + + if (!availableLocaleCodes.includes(requestedLocale as string)) { + throw new APIError( + `Validation locale "${String(requestedLocale)}" is not available.`, + httpStatus.BAD_REQUEST, + ) + } + } + + return locales +} + +/** + * A request that already carries a transaction ID shares a database session with the transaction + * it was cloned from. Concurrent operations on one session are unsafe, so locale passes must run + * one at a time rather than with the default concurrency. + */ +export function resolveValidationConcurrency( + req: Partial | undefined, +): number | undefined { + return req?.transactionID ? 1 : undefined +} + +export async function runValidationLocalePasses({ + concurrency = validationLocaleConcurrency, + locales, + validate, +}: { + /** + * Maximum number of locale passes to run at once. Pass `1` when the request being validated + * shares a database session with an already-open transaction, since concurrent operations on + * one session are unsafe. + * @default 3 + */ + concurrency?: number + locales: TypedLocale[] + validate: (locale: TypedLocale) => Promise +}): Promise { + const batchSize = Math.max(1, Math.min(concurrency, locales.length)) + const results: TResult[] = [] + + for (let batchStart = 0; batchStart < locales.length; batchStart += batchSize) { + const batch = locales.slice(batchStart, batchStart + batchSize) + results.push(...(await Promise.all(batch.map((locale) => validate(locale))))) + } + + return results +} + +export function cloneValidationRequest( + request: Partial | undefined, +): Partial { + if (!request) { + return {} + } + + const payloadRequest: Partial = request + const fetchRequest: Request | undefined = + typeof Request !== 'undefined' && request instanceof Request ? request : undefined + const canCloneFetchRequest = fetchRequest && !fetchRequest.bodyUsed + let clonedRequest: Record + + if (canCloneFetchRequest) { + clonedRequest = fetchRequest.clone() as unknown as Record + } else { + clonedRequest = {} + } + + for (const [key, value] of Object.entries(payloadRequest)) { + if (key === 'payloadDataLoader') { + continue + } + + clonedRequest[key] = sharedValidationRequestProperties.has(key) + ? value + : cloneValidationValue(value) + } + + // `context`/`query`/`routeParams` default to an empty object even when the source request never + // set them, since downstream code reads their properties without checking for `undefined` first. + Object.assign(clonedRequest, { + context: cloneValidationValue(payloadRequest.context ?? {}), + query: cloneValidationValue(payloadRequest.query ?? {}), + routeParams: cloneValidationValue(payloadRequest.routeParams ?? {}), + }) + + if (!canCloneFetchRequest) { + // `headers`/`method`/`signal`/`url` can also come from a Request-like object's prototype, so + // copy them explicitly when there is no usable native Fetch Request clone to preserve them. + // REST request bodies have already been consumed, but cached own methods such as `json` were + // copied by the loop above and remain available to hooks. + Object.assign(clonedRequest, { + headers: cloneValidationValue(payloadRequest.headers), + method: payloadRequest.method, + signal: payloadRequest.signal, + url: payloadRequest.url, + }) + } + + return clonedRequest as Partial +} + +export function cloneValidationValue(value: T, cache = new WeakMap()): T { + if ((typeof value !== 'object' && typeof value !== 'function') || value === null) { + return value + } + + if (typeof value === 'function' || value instanceof Promise) { + return value + } + + const objectValue = value as object + const cachedValue = cache.get(objectValue) + + if (cachedValue) { + return cachedValue as T + } + + if (value instanceof Headers) { + return new Headers(value) as T + } + + if (value instanceof URLSearchParams) { + return new URLSearchParams(value) as T + } + + if (value instanceof URL) { + return new URL(value) as T + } + + if (value instanceof Date) { + return new Date(value) as T + } + + if (value instanceof RegExp) { + return new RegExp(value.source, value.flags) as T + } + + if (value instanceof ArrayBuffer) { + return value.slice(0) as T + } + + if (ArrayBuffer.isView(value)) { + if (Buffer.isBuffer(value)) { + return Buffer.from(value) as T + } + + if (value instanceof DataView) { + return new DataView(value.buffer.slice(0), value.byteOffset, value.byteLength) as T + } + + return new (value.constructor as new (input: typeof value) => typeof value)(value) + } + + if (value instanceof Map) { + const clonedMap = new Map() + cache.set(objectValue, clonedMap) + for (const [key, mapValue] of value) { + clonedMap.set(cloneValidationValue(key, cache), cloneValidationValue(mapValue, cache)) + } + return clonedMap as T + } + + if (value instanceof Set) { + const clonedSet = new Set() + cache.set(objectValue, clonedSet) + for (const setValue of value) { + clonedSet.add(cloneValidationValue(setValue, cache)) + } + return clonedSet as T + } + + if (typeof Blob !== 'undefined' && value instanceof Blob) { + return value + } + + const prototype = Object.getPrototypeOf(value) + + if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) { + return value + } + + const clonedValue: Record | unknown[] = Array.isArray(value) + ? [] + : Object.create(prototype) + cache.set(objectValue, clonedValue) + + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key) + + if (descriptor?.enumerable) { + ;(clonedValue as Record)[key] = cloneValidationValue( + (value as Record)[key], + cache, + ) + } + } + + return clonedValue as T +} diff --git a/packages/payload/src/utilities/runLocaleScopedValidation.ts b/packages/payload/src/utilities/runLocaleScopedValidation.ts new file mode 100644 index 00000000000..f092df6015c --- /dev/null +++ b/packages/payload/src/utilities/runLocaleScopedValidation.ts @@ -0,0 +1,151 @@ +import type { ValidationResult } from '../collections/operations/local/validate.js' +import type { ValidationFieldError } from '../errors/index.js' +import type { Field } from '../fields/config/types.js' +import type { Payload, RequestContext, SanitizedConfig, User } from '../index.js' +import type { JsonObject, PayloadRequest } from '../types/index.js' + +import { createPayloadRequest } from './createPayloadRequest.js' +import { isValidationErrorPathLocalized } from './isValidationErrorPathLocalized.js' +import { projectNonLocalizedData } from './projectNonLocalizedData.js' +import { + cloneValidationRequest, + cloneValidationValue, + resolveValidationConcurrency, + resolveValidationLocales, + runValidationLocalePasses, + type ValidationLocaleSelector, +} from './resolveValidationLocales.js' + +/** + * Clones the caller's request into one scoped to `validate`, resolves the selected locales, runs + * `runPass` once per locale against an independent request/data clone, and aggregates the field + * errors. Shared by the collection and global local validate wrappers so the locale-cloning and + * pass-running plumbing has one owner instead of two copies that can drift apart. + */ +export async function runLocaleScopedValidation({ + context, + data, + fields, + locale, + payload, + req, + runPass, + user, + validationDataLocale, +}: { + context: RequestContext | undefined + data: TData + fields: Field[] + locale: ValidationLocaleSelector + payload: Payload + req: Partial | undefined + runPass: (args: { + data: TData + onValidationData: (data: JsonObject) => void + req: PayloadRequest + }) => Promise + user: null | undefined | User + validationDataLocale: string | undefined +}): Promise { + const baseReq = await createPayloadRequest({ + context: cloneValidationValue(context), + fallbackLocale: false, + payload, + req: cloneValidationRequest(req), + user: cloneValidationValue(user), + }) + baseReq.operation = 'validate' + const locales = await resolveValidationLocales({ + locale, + req: baseReq, + }) + const results = await runValidationLocalePasses({ + concurrency: resolveValidationConcurrency(req), + locales, + validate: async (validationLocale) => { + const localeReq = await createPayloadRequest({ + fallbackLocale: false, + locale: validationLocale ?? undefined, + payload, + req: cloneValidationRequest(baseReq), + }) + const validationCandidateData = cloneValidationValue(data) + const validationData: TData = + validationDataLocale && validationLocale !== validationDataLocale && validationCandidateData + ? (projectNonLocalizedData({ + configBlockReferences: payload.config.blocks, + data: validationCandidateData as JsonObject, + fields, + }) as TData) + : validationCandidateData + + let mergedValidationData = validationData as JsonObject + const result = await runPass({ + data: validationData, + onValidationData: (mergedData) => { + mergedValidationData = mergedData + }, + req: localeReq, + }) + + return { data: mergedValidationData, result } + }, + }) + const rawErrors = results.flatMap(({ result }) => result.errors) + + // A non-localized field carries one shared value, so every locale pass validates it + // identically and would otherwise report the same failure once per resolved locale. + const errors = + locales.length > 1 + ? dedupeNonLocalizedFieldErrors({ + configBlockReferences: payload.config.blocks, + errors: results.flatMap(({ data: validationData, result }) => + result.errors.map((error) => ({ data: validationData, error })), + ), + fields, + }) + : rawErrors + + return { + errors, + valid: errors.length === 0, + } +} + +function dedupeNonLocalizedFieldErrors({ + configBlockReferences, + errors, + fields, +}: { + configBlockReferences: SanitizedConfig['blocks'] + errors: { data: JsonObject; error: ValidationFieldError }[] + fields: Field[] +}): ValidationFieldError[] { + const seenNonLocalizedErrors = new Set() + const deduped: ValidationFieldError[] = [] + + for (const { data, error } of errors) { + const isLocalized = isValidationErrorPathLocalized({ + configBlockReferences, + data, + fields, + path: error.path, + }) + + if (isLocalized) { + deduped.push(error) + continue + } + + const errorIdentity = JSON.stringify([error.path, error.message]) + + if (seenNonLocalizedErrors.has(errorIdentity)) { + continue + } + + seenNonLocalizedErrors.add(errorIdentity) + deduped.push({ ...error, locale: undefined }) + } + + return deduped +} diff --git a/packages/payload/src/utilities/toValidationResult.ts b/packages/payload/src/utilities/toValidationResult.ts new file mode 100644 index 00000000000..61b71fb578a --- /dev/null +++ b/packages/payload/src/utilities/toValidationResult.ts @@ -0,0 +1,30 @@ +import type { ValidationResult } from '../collections/operations/local/validate.js' +import type { PayloadRequest } from '../types/index.js' + +import { ValidationError } from '../errors/index.js' + +/** + * Maps a caught `ValidationError` to a `ValidationResult`, tagging each field error with the + * request's locale. Rethrows any other error, since only field validation failures are part of + * the validate operation's contract. Meant to be called from a `catch` block around the + * `beforeValidate`/`beforeChange` hook sequence. + */ +export function toValidationResult({ + error, + req, +}: { + error: unknown + req: PayloadRequest +}): ValidationResult { + if (!(error instanceof ValidationError)) { + throw error + } + + return { + errors: error.data.errors.map((validationError) => ({ + ...validationError, + locale: req.locale ?? undefined, + })), + valid: false, + } +} diff --git a/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts b/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts index 68b25b91415..83cbdbc9d45 100644 --- a/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts +++ b/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts @@ -4,6 +4,7 @@ import type { AccessResult } from '../../config/types.js' import type { FindGlobalVersionsArgs, FindVersionsArgs } from '../../database/types.js' import type { SanitizedGlobalConfig } from '../../globals/config/types.js' import type { PayloadRequest, SelectType, Where } from '../../types/index.js' +import type { TypeWithVersion } from '../types.js' import { hasWhereAccessResult } from '../../auth/index.js' import { combineQueries } from '../../database/combineQueries.js' @@ -23,14 +24,41 @@ type Arguments = { select?: SelectType } +type FindDraftVersionArguments = { + draftVersionID?: number | string +} & Arguments + export const replaceWithDraftIfAvailable = async ({ accessResult, doc, entity, entityType, + overrideAccess, req, select, }: Arguments): Promise => { + const draftVersion = await findDraftVersion({ + accessResult, + doc, + entity, + entityType, + overrideAccess, + req, + select, + }) + + return draftVersion ? getDocumentFromDraftVersion({ doc, draftVersion, entityType }) : doc +} + +export const findDraftVersion = async ({ + accessResult, + doc, + draftVersionID, + entity, + entityType, + req, + select, +}: FindDraftVersionArguments): Promise | undefined> => { const { locale, payload } = req let queryToBuild: Where = { @@ -80,6 +108,14 @@ export const replaceWithDraftIfAvailable = async ({ }) } + if (draftVersionID !== undefined) { + queryToBuild.and!.push({ + id: { + equals: draftVersionID, + }, + }) + } + if (docHasTimestamps(doc)) { queryToBuild.and!.push({ or: [ @@ -122,13 +158,19 @@ export const replaceWithDraftIfAvailable = async ({ versionDocs = (await req.payload.db.findVersions(findVersionsArgs)).docs } - let draft = versionDocs[0] - - if (!draft) { - return doc - } + return versionDocs[0] +} - draft = sanitizeInternalFields(draft) +export const getDocumentFromDraftVersion = ({ + doc, + draftVersion, + entityType, +}: { + doc: T + draftVersion: TypeWithVersion + entityType: 'collection' | 'global' +}): T => { + const draft = sanitizeInternalFields(draftVersion) // Patch globalType onto version doc if (entityType === 'global' && 'globalType' in doc) { diff --git a/packages/payload/src/versions/saveVersion.ts b/packages/payload/src/versions/saveVersion.ts index eadc0fa29e9..d68cca14455 100644 --- a/packages/payload/src/versions/saveVersion.ts +++ b/packages/payload/src/versions/saveVersion.ts @@ -4,6 +4,7 @@ import type { CreateGlobalVersionArgs, CreateVersionArgs, Payload } from '../ind import type { JsonObject, PayloadRequest, SelectType } from '../types/index.js' import { deepCopyObjectSimple } from '../index.js' +import { assertNoValidationWrite } from '../utilities/assertNoValidationWrite.js' import { getVersionsMax, hasLocalizeStatusEnabled } from '../utilities/getVersionsConfig.js' import { sanitizeInternalFields } from '../utilities/sanitizeInternalFields.js' import { getQueryDraftsSelect } from './drafts/getQueryDraftsSelect.js' @@ -48,6 +49,8 @@ export async function saveVersion({ select, unpublish, }: Args): Promise { + assertNoValidationWrite(req) + let result: JsonObject | undefined let createdNewVersion = false const now = new Date().toISOString() diff --git a/packages/payload/src/versions/schedule/job.ts b/packages/payload/src/versions/schedule/job.ts index 4c5ebe86715..62500656eb5 100644 --- a/packages/payload/src/versions/schedule/job.ts +++ b/packages/payload/src/versions/schedule/job.ts @@ -39,36 +39,46 @@ export const getSchedulePublishTask = ({ user.collection = input.user.relationTo } + const isPublishAllLocales = input.locale === undefined + if (input.doc) { + const collectionSlug = input.doc.relationTo + // input.doc.value is always a string (#10481); coerce back to the real ID type. const idType = - req.payload.collections[input.doc.relationTo]?.customIDType ?? + req.payload.collections[collectionSlug]?.customIDType ?? req.payload.db?.defaultIDType ?? 'text' const id = idType === 'number' ? Number(input.doc.value) : input.doc.value await req.payload.update({ id, - collection: input.doc.relationTo, + collection: collectionSlug, data: { _status, }, depth: 0, locale: input.locale, overrideAccess: user === null, + publishAllLocales: _status === 'published' && isPublishAllLocales, + req, user, }) } if (input.global) { + const globalSlug = input.global + await req.payload.updateGlobal({ - slug: input.global, + slug: globalSlug, data: { _status, }, depth: 0, locale: input.locale, overrideAccess: user === null, + publishAllLocales: _status === 'published' && isPublishAllLocales, + req, user, }) } diff --git a/packages/plugin-multi-tenant/src/types.ts b/packages/plugin-multi-tenant/src/types.ts index d62a66ac8ad..0f3dea909e2 100644 --- a/packages/plugin-multi-tenant/src/types.ts +++ b/packages/plugin-multi-tenant/src/types.ts @@ -270,7 +270,7 @@ type AllAccessKeysT = T[number] extends keyof Omit< : never export type AllAccessKeys = AllAccessKeysT< - ['create', 'read', 'update', 'delete', 'readVersions', 'unlock'] + ['create', 'read', 'update', 'delete', 'readVersions', 'unlock', 'validate'] > export type CollectionAccessResultOverride = ({ diff --git a/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.ts b/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.ts index cb8bc1d4af9..8555fd4d407 100644 --- a/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.ts +++ b/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.ts @@ -11,6 +11,7 @@ export const collectionAccessKeys: AllAccessKeys = [ 'delete', 'readVersions', 'unlock', + 'validate', ] as const export type TenantAccessConfig = { @@ -75,10 +76,15 @@ const getTenantAccessResult = ({ const wrapCollectionAccess = (scope: TenantAccessConfig): void => { scope.collection.access ??= {} + const updateAccessFallback = scope.collection.access.update for (const accessKey of collectionAccessKeys) { + // A collection without its own `validate` access is governed by `update`, matching core's + // fallback contract, rather than the generic "any authenticated user" default below. const accessFunction = - scope.collection.access[accessKey] ?? (({ req }: AccessArgs) => Boolean(req.user)) + scope.collection.access[accessKey] ?? + (accessKey === 'validate' ? updateAccessFallback : undefined) ?? + (({ req }: AccessArgs) => Boolean(req.user)) scope.collection.access[accessKey] = async (args) => getTenantAccessResult({ @@ -115,8 +121,12 @@ export const addCollectionAccess = ({ config.baseAccess ??= {} config.baseAccess.collections ??= {} + const originalBaseAccess = { ...config.baseAccess.collections } + for (const accessKey of collectionAccessKeys) { - const baseAccessFunction = config.baseAccess.collections[accessKey] + const baseAccessFunction = + originalBaseAccess[accessKey] ?? + (accessKey === 'validate' ? originalBaseAccess.update : undefined) config.baseAccess.collections[accessKey] = async (args) => { const baseResult = baseAccessFunction ? await baseAccessFunction(args) : true diff --git a/packages/richtext-lexical/src/features/blocks/server/validate.ts b/packages/richtext-lexical/src/features/blocks/server/validate.ts index acfd6f2eef5..855225ac572 100644 --- a/packages/richtext-lexical/src/features/blocks/server/validate.ts +++ b/packages/richtext-lexical/src/features/blocks/server/validate.ts @@ -40,7 +40,7 @@ export const blockValidationHOC = ( fields: block.fields, fieldSchemaMap: undefined, initialBlockData: blockFieldData, - operation: operation === 'create' || operation === 'update' ? operation : 'update', + operation, permissions: {}, preferences, renderAllFields: false, diff --git a/packages/richtext-lexical/src/features/link/server/validate.ts b/packages/richtext-lexical/src/features/link/server/validate.ts index e66dcdf8674..2aa99c55f00 100644 --- a/packages/richtext-lexical/src/features/link/server/validate.ts +++ b/packages/richtext-lexical/src/features/link/server/validate.ts @@ -28,7 +28,7 @@ export const linkValidation = ( fields: sanitizedFieldsWithoutText, // Sanitized in feature.server.ts fieldSchemaMap: undefined, initialBlockData: node.fields, - operation: operation === 'create' || operation === 'update' ? operation : 'update', + operation, permissions: {}, preferences, renderAllFields: false, diff --git a/packages/richtext-lexical/src/features/typesServer.ts b/packages/richtext-lexical/src/features/typesServer.ts index 7523a903d41..810af5ce461 100644 --- a/packages/richtext-lexical/src/features/typesServer.ts +++ b/packages/richtext-lexical/src/features/typesServer.ts @@ -165,7 +165,7 @@ export type AfterChangeNodeHookArgs = { } export type BeforeValidateNodeHookArgs = { /** A string relating to which operation the field type is currently executing within. Useful within beforeValidate, beforeChange, and afterChange hooks to differentiate between create and update operations. */ - operation: 'create' | 'delete' | 'read' | 'update' + operation: 'create' | 'delete' | 'read' | 'update' | 'validate' /** The value of the node before any changes. Not available in afterRead hooks */ originalNode: T overrideAccess: boolean @@ -178,7 +178,7 @@ export type BeforeChangeNodeHookArgs = { errors: ValidationFieldError[] mergeLocaleActions: (() => Promise | void)[] /** A string relating to which operation the field type is currently executing within. Useful within beforeValidate, beforeChange, and afterChange hooks to differentiate between create and update operations. */ - operation: 'create' | 'delete' | 'read' | 'update' + operation: 'create' | 'delete' | 'read' | 'update' | 'validate' /** The value of the node before any changes. Not available in afterRead hooks */ originalNode: T /** diff --git a/packages/richtext-lexical/src/features/upload/server/validate.ts b/packages/richtext-lexical/src/features/upload/server/validate.ts index 119dc5c2138..ffa80d448e7 100644 --- a/packages/richtext-lexical/src/features/upload/server/validate.ts +++ b/packages/richtext-lexical/src/features/upload/server/validate.ts @@ -55,7 +55,7 @@ export const uploadValidation = ( fields: collection.fields, fieldSchemaMap: undefined, initialBlockData: node?.fields ?? {}, - operation: operation === 'create' || operation === 'update' ? operation : 'update', + operation, permissions: {}, preferences, renderAllFields: false, diff --git a/packages/richtext-lexical/src/utilities/buildInitialState.ts b/packages/richtext-lexical/src/utilities/buildInitialState.ts index bc1b30f363d..85b4147d186 100644 --- a/packages/richtext-lexical/src/utilities/buildInitialState.ts +++ b/packages/richtext-lexical/src/utilities/buildInitialState.ts @@ -81,7 +81,7 @@ export async function buildInitialState({ fields: (context.fieldSchemaMap.get(schemaFieldsPath) as any)?.fields, fieldSchemaMap: context.fieldSchemaMap, initialBlockData: blockNode.fields, - operation: context.operation as any, // TODO: Type + operation: context.operation, permissions: true, preferences: context.preferences, readOnly: context.disabled, diff --git a/packages/ui/src/elements/DocumentControls/index.tsx b/packages/ui/src/elements/DocumentControls/index.tsx index 3ffb8a674d5..af0ae868d47 100644 --- a/packages/ui/src/elements/DocumentControls/index.tsx +++ b/packages/ui/src/elements/DocumentControls/index.tsx @@ -49,7 +49,7 @@ import './index.css' const baseClass = 'doc-controls' -export const DocumentControls: React.FC<{ +type DocumentControlsProps = { readonly apiURL: string readonly BeforeDocumentControls?: React.ReactNode readonly BeforeDocumentMeta?: React.ReactNode @@ -92,7 +92,9 @@ export const DocumentControls: React.FC<{ * - `drawerHeaderActions`: only the action buttons, rendered in the document drawer header. */ readonly variant?: 'default' | 'drawerHeaderActions' -}> = (props) => { +} + +export const DocumentControls: React.FC = (props) => { const { id, slug, diff --git a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts index 97736c373e6..1861a2fdd63 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts +++ b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts @@ -9,6 +9,7 @@ import type { FlattenedBlock, FormState, FormStateWithoutComponents, + Operation, PayloadRequest, Row, SanitizedFieldPermissions, @@ -76,7 +77,7 @@ export type AddFieldStatePromiseArgs = { * Whether to omit parent fields in the state. @default false */ omitParents?: boolean - operation: 'create' | 'update' + operation: Operation parentIndexPath: string parentPath: string parentPermissions: SanitizedFieldsPermissions diff --git a/packages/ui/src/forms/fieldSchemasToFormState/index.tsx b/packages/ui/src/forms/fieldSchemasToFormState/index.tsx index 849c0ab4284..d8436a3be24 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/index.tsx +++ b/packages/ui/src/forms/fieldSchemasToFormState/index.tsx @@ -7,6 +7,7 @@ import type { FieldSchemaMap, FormState, FormStateWithoutComponents, + Operation, PayloadRequest, SanitizedFieldsPermissions, SelectMode, @@ -59,7 +60,7 @@ type Args = { */ initialBlockData?: Data mockRSCs?: BuildFormStateArgs['mockRSCs'] - operation?: 'create' | 'update' + operation?: Operation permissions: SanitizedFieldsPermissions preferences: DocumentPreferences /** diff --git a/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts b/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts index 636f0bc83d3..14853a87f40 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts +++ b/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts @@ -7,6 +7,7 @@ import type { FieldSchemaMap, FormState, FormStateWithoutComponents, + Operation, PayloadRequest, SanitizedFieldsPermissions, SelectMode, @@ -57,7 +58,7 @@ type Args = { /** * operation is only needed for validation */ - operation: 'create' | 'update' + operation: Operation parentIndexPath: string parentPassesCondition?: boolean parentPath: string diff --git a/packages/ui/src/views/CreateFirstUser/index.tsx b/packages/ui/src/views/CreateFirstUser/index.tsx index 6bb056a7ad4..47aa22ffd86 100644 --- a/packages/ui/src/views/CreateFirstUser/index.tsx +++ b/packages/ui/src/views/CreateFirstUser/index.tsx @@ -52,7 +52,7 @@ export async function CreateFirstUserView({ initPageResult }: AdminViewServerPro const baseFields: SanitizedFieldsPermissions = Object.fromEntries( collectionConfig.fields .filter((f): f is { name: string } & typeof f => 'name' in f && typeof f.name === 'string') - .map((f) => [f.name, { create: true, read: true, update: true }]), + .map((f) => [f.name, { create: true, read: true, update: true, validate: true }]), ) // In create-first-user we should always allow all fields @@ -63,6 +63,7 @@ export async function CreateFirstUserView({ initPageResult }: AdminViewServerPro read: true, readVersions: true, update: true, + validate: true, } // Build initial form state from data From 04c809bb8dd2911374f62f9bdbed9b0366176d11 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Fri, 2 Oct 2026 12:43:24 +0100 Subject: [PATCH 02/12] test: reduce validation test duplication Written with AI --- .../src/utilities/addCollectionAccess.spec.ts | 52 +- test/validate/int.spec.ts | 617 +++++------------- 2 files changed, 202 insertions(+), 467 deletions(-) diff --git a/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.spec.ts b/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.spec.ts index 922a33a05ce..3d46539bd76 100644 --- a/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.spec.ts +++ b/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.spec.ts @@ -65,43 +65,53 @@ describe('addCollectionAccess', () => { await expect(config.baseAccess?.collections?.create?.(createArgs())).resolves.toBe(true) }) - it('falls back to base update access for validate when validate access is not configured', async () => { - const baseUpdate = vi.fn(() => false) + it('uses explicit base validate access and otherwise falls back to update access', async () => { const collection: CollectionConfig = { slug: 'posts', fields: [] } - const config = { + let fallbackUpdateCalls = 0 + const fallbackConfig = { baseAccess: { collections: { - update: baseUpdate, + update: () => { + fallbackUpdateCalls += 1 + return false + }, }, }, } as Config - addCollectionAccess({ config, scopes: [createScope(collection)] }) + addCollectionAccess({ config: fallbackConfig, scopes: [createScope(collection)] }) - await expect(config.baseAccess?.collections?.validate?.(createArgs())).resolves.toBe(false) - expect(baseUpdate).toHaveBeenCalledOnce() - }) + await expect(fallbackConfig.baseAccess?.collections?.validate?.(createArgs())).resolves.toBe( + false, + ) + expect(fallbackUpdateCalls).toBe(1) - it('prefers explicit base validate access over base update access', async () => { - const baseUpdate = vi.fn(() => false) - const baseValidate = vi.fn(() => true) - const collection: CollectionConfig = { slug: 'posts', fields: [] } - const config = { + let explicitUpdateCalls = 0 + let explicitValidateCalls = 0 + const explicitConfig = { baseAccess: { collections: { - update: baseUpdate, - validate: baseValidate, + update: () => { + explicitUpdateCalls += 1 + return false + }, + validate: () => { + explicitValidateCalls += 1 + return true + }, }, }, } as Config - addCollectionAccess({ config, scopes: [createScope(collection)] }) + addCollectionAccess({ config: explicitConfig, scopes: [createScope(collection)] }) - await expect(config.baseAccess?.collections?.validate?.(createArgs())).resolves.toEqual({ - tenant: { in: ['tenant-1'] }, - }) - expect(baseValidate).toHaveBeenCalledOnce() - expect(baseUpdate).not.toHaveBeenCalled() + await expect(explicitConfig.baseAccess?.collections?.validate?.(createArgs())).resolves.toEqual( + { + tenant: { in: ['tenant-1'] }, + }, + ) + expect(explicitUpdateCalls).toBe(0) + expect(explicitValidateCalls).toBe(1) }) it('keeps callback wrapping when an access result override is configured', async () => { diff --git a/test/validate/int.spec.ts b/test/validate/int.spec.ts index ae0caa2908f..cff53e27ffd 100644 --- a/test/validate/int.spec.ts +++ b/test/validate/int.spec.ts @@ -77,9 +77,20 @@ test.suite('validate Local API', { config: './config.ts' }, () => { }) test.describe('collections', () => { - test('should fall back to collection update access with the validate operation', async ({ - payload, - }) => { + test('should use collection update access as the validate fallback', async ({ payload }) => { + await expect( + payload.validate({ + collection: validationFallbackCollectionSlug, + data: { + title: 'Candidate title', + }, + locale: 'en', + overrideAccess: false, + }), + ).rejects.toMatchObject({ + status: 403, + }) + await expect( payload.validate({ collection: validationFallbackCollectionSlug, @@ -104,23 +115,6 @@ test.suite('validate Local API', { config: './config.ts' }, () => { expect(fallbackAccessEvents.every(({ operation }) => operation === 'validate')).toBe(true) }) - test('should deny collection validation when its update access fallback denies it', async ({ - payload, - }) => { - await expect( - payload.validate({ - collection: validationFallbackCollectionSlug, - data: { - title: 'Candidate title', - }, - locale: 'en', - overrideAccess: false, - }), - ).rejects.toMatchObject({ - status: 403, - }) - }) - test('should prefer explicit collection validate access over its update access fallback', async ({ payload, }) => { @@ -490,52 +484,30 @@ test.suite('validate Local API', { config: './config.ts' }, () => { }) }) - test('should reject a null localized array when validating a secondary collection locale', async ({ + test('should reject null localized rows when validating a secondary collection locale', async ({ payload, }) => { - const result = await payload.validate({ - collection: publishCollectionSlug, - data: { - ...getPublishCollectionLocaleData({ title: 'Spanish candidate' }), - localizedArray: null, - } as never, - locale: 'es', - overrideAccess: true, - }) - - expect(result).toMatchObject({ - errors: [ - { - locale: 'es', - path: 'localizedArray', - }, - ], - valid: false, - }) - }) - - test('should reject null localized blocks when validating a secondary collection locale', async ({ - payload, - }) => { - const result = await payload.validate({ - collection: publishCollectionSlug, - data: { - ...getPublishCollectionLocaleData({ title: 'Spanish candidate' }), - localizedBlocks: null, - } as never, - locale: 'es', - overrideAccess: true, - }) + for (const fieldName of ['localizedArray', 'localizedBlocks'] as const) { + const result = await payload.validate({ + collection: publishCollectionSlug, + data: { + ...getPublishCollectionLocaleData({ title: 'Spanish candidate' }), + [fieldName]: null, + } as never, + locale: 'es', + overrideAccess: true, + }) - expect(result).toMatchObject({ - errors: [ - { - locale: 'es', - path: 'localizedBlocks', - }, - ], - valid: false, - }) + expect(result).toMatchObject({ + errors: [ + { + locale: 'es', + path: fieldName, + }, + ], + valid: false, + }) + } }) test('should accept null localized arrays and blocks when saving a secondary collection locale', async ({ @@ -1064,27 +1036,6 @@ test.suite('validate Local API', { config: './config.ts' }, () => { }) }) - test('should return a successful result for valid create data', async ({ payload }) => { - const req = { - operation: 'read', - } satisfies Partial - const result = await payload.validate({ - collection: validationCollectionSlug, - data: { - summary: 'candidate summary', - title: 'Candidate title', - }, - locale: 'en', - req, - }) - - expect(result).toEqual({ - errors: [], - valid: true, - }) - expect(req.operation).toBe('read') - }) - test('should run validation hooks in order with the validate operation and unchanged context', async ({ payload, }) => { @@ -1121,7 +1072,7 @@ test.suite('validate Local API', { config: './config.ts' }, () => { ).toBe(true) }) - test('should pass the validate operation into fields nested inside a Lexical block', async ({ + test('should pass the validate operation into nested Lexical field hooks', async ({ payload, }) => { const result = await payload.validate({ @@ -1143,22 +1094,6 @@ test.suite('validate Local API', { config: './config.ts' }, () => { expect(nestedBlockFieldValidateEvent).toBeDefined() expect(nestedBlockFieldValidateEvent?.operation).toBe('validate') expect(nestedBlockFieldValidateEvent?.requestOperation).toBe('validate') - }) - - test('should pass the validate operation into beforeChange hooks nested inside a Lexical block', async ({ - payload, - }) => { - const result = await payload.validate({ - collection: validationCollectionSlug, - data: { - blockRichText: buildNestedFieldValidateBlockRichText('nested block value'), - summary: 'candidate summary', - title: 'Candidate title', - }, - locale: 'en', - }) - - expect(result.valid).toBe(true) const nestedBlockFieldBeforeChangeEvent = hookEvents.find( ({ hook }) => hook === 'nestedBlockFieldBeforeChange', @@ -1185,25 +1120,30 @@ test.suite('validate Local API', { config: './config.ts' }, () => { expect(hookEvents.find(({ hook }) => hook === 'fieldValidate')).toBeDefined() }) - test('should reject create simulation without data at runtime', async ({ payload }) => { - await expect( - payload.validate({ - collection: validationCollectionSlug, - locale: 'en', - } as never), - ).rejects.toThrow('Validation create simulation requires data') - }) - - test('should reject a missing locale at runtime', async ({ payload }) => { - await expect( - payload.validate({ - collection: validationCollectionSlug, - data: { - summary: 'candidate summary', - title: 'Candidate title', + test('should reject missing required runtime arguments', async ({ payload }) => { + const invalidArguments = [ + { + args: { + collection: validationCollectionSlug, + locale: 'en', }, - } as never), - ).rejects.toThrow('Validation requires a locale') + errorMessage: 'Validation create simulation requires data', + }, + { + args: { + collection: validationCollectionSlug, + data: { + summary: 'candidate summary', + title: 'Candidate title', + }, + }, + errorMessage: 'Validation requires a locale', + }, + ] + + for (const { args, errorMessage } of invalidArguments) { + await expect(payload.validate(args as never)).rejects.toThrow(errorMessage) + } }) test('should merge partial update data over the stored locale without persisting it', async ({ @@ -1662,9 +1602,20 @@ test.suite('validate Local API', { config: './config.ts' }, () => { }) test.describe('globals', () => { - test('should fall back to global update access with the validate operation', async ({ - payload, - }) => { + test('should use global update access as the validate fallback', async ({ payload }) => { + await expect( + payload.validateGlobal({ + slug: validationFallbackGlobalSlug, + data: { + title: 'Candidate title', + }, + locale: 'en', + overrideAccess: false, + }), + ).rejects.toMatchObject({ + status: 403, + }) + await expect( payload.validateGlobal({ slug: validationFallbackGlobalSlug, @@ -1689,23 +1640,6 @@ test.suite('validate Local API', { config: './config.ts' }, () => { expect(fallbackAccessEvents.every(({ operation }) => operation === 'validate')).toBe(true) }) - test('should deny global validation when its update access fallback denies it', async ({ - payload, - }) => { - await expect( - payload.validateGlobal({ - slug: validationFallbackGlobalSlug, - data: { - title: 'Candidate title', - }, - locale: 'en', - overrideAccess: false, - }), - ).rejects.toMatchObject({ - status: 403, - }) - }) - test('should let an explicit null user override an authenticated reused global request', async ({ payload, }) => { @@ -2311,65 +2245,23 @@ test.suite('validate Local API', { config: './config.ts' }, () => { }) test.describe('REST API', () => { - test('should use collection update access when validate access is not configured', async ({ - restClient, - }) => { - const response = await restClient.POST( - `/${validationFallbackCollectionSlug}/validate?locale=en`, - { - body: JSON.stringify({ - title: 'Candidate title', - }), - }, - ) - - expect(response.status).toBe(200) - await expect(response.json()).resolves.toEqual({ - errors: [], - valid: true, - }) - expect(fallbackAccessEvents).toContainEqual({ - operation: 'validate', - source: 'collection', - }) - }) - - test('should use global update access when validate access is not configured', async ({ + test('should deny REST validation when explicit validate access denies it', async ({ restClient, }) => { - const response = await restClient.POST( - `/globals/${validationFallbackGlobalSlug}/validate?locale=en`, - { - body: JSON.stringify({ - title: 'Candidate title', - }), - }, - ) - - expect(response.status).toBe(200) - await expect(response.json()).resolves.toEqual({ - errors: [], - valid: true, - }) - expect(fallbackAccessEvents).toContainEqual({ - operation: 'validate', - source: 'global', - }) - }) - - test('should deny global REST validation when explicit validate access denies it', async ({ - restClient, - }) => { - const response = await restClient.POST( + const endpoints: `/${string}`[] = [ `/globals/${validationDeniedGlobalSlug}/validate?locale=en`, - { + `/${validationDeniedCollectionSlug}/validate?locale=en`, + ] + + for (const endpoint of endpoints) { + const response = await restClient.POST(endpoint, { body: JSON.stringify({ title: 'Candidate title', }), - }, - ) + }) - expect(response.status).toBe(403) + expect(response.status).toBe(403) + } }) test('should return 404 for a nonexistent collection document', async ({ @@ -2422,53 +2314,38 @@ test.suite('validate Local API', { config: './config.ts' }, () => { }) }) - test('should return valid collection create validation', async ({ restClient }) => { - const response = await restClient.POST(`/${validationCollectionSlug}/validate?locale=en`, { - body: JSON.stringify({ - summary: 'candidate summary', - title: 'Candidate title', - }), - }) - - expect(response.status).toBe(200) - await expect(response.json()).resolves.toEqual({ - errors: [], - valid: true, - }) - }) - - test('should return 400 for missing or empty locales and malformed data', async ({ - restClient, - }) => { - const missingLocale = await restClient.POST(`/${validationCollectionSlug}/validate`, { - body: JSON.stringify({ - summary: 'candidate summary', - title: 'Candidate title', - }), - }) - const emptyLocale = await restClient.POST(`/${validationCollectionSlug}/validate?locale=`, { - body: JSON.stringify({ - summary: 'candidate summary', - title: 'Candidate title', - }), - }) - const malformedData = await restClient.POST( - `/${validationCollectionSlug}/validate?locale=en`, + test('should return 400 for invalid REST input', async ({ restClient }) => { + const invalidInputs: Array<{ + body: unknown + endpoint: `/${string}` + expectedMessage: string + }> = [ { - body: JSON.stringify([]), + body: { summary: 'candidate summary', title: 'Candidate title' }, + endpoint: `/${validationCollectionSlug}/validate`, + expectedMessage: 'Validation requires a locale.', }, - ) - const malformedJSON = await restClient.POST( - `/${validationCollectionSlug}/validate?locale=en`, { - body: '{ invalid json', + body: { summary: 'candidate summary', title: 'Candidate title' }, + endpoint: `/${validationCollectionSlug}/validate?locale=`, + expectedMessage: 'Validation requires a locale.', }, - ) + { + body: [], + endpoint: `/${validationCollectionSlug}/validate?locale=en`, + expectedMessage: 'Validation data must be an object.', + }, + ] + + for (const { body, endpoint, expectedMessage } of invalidInputs) { + const response = await restClient.POST(endpoint, { + body: JSON.stringify(body), + }) + const result = await response.json() - expect(missingLocale.status).toBe(400) - expect(emptyLocale.status).toBe(400) - expect(malformedData.status).toBe(400) - expect(malformedJSON.status).toBe(400) + expect(response.status).toBe(400) + expect(result.errors).toEqual([expect.objectContaining({ message: expectedMessage })]) + } }) test('should accept repeated and all locale selectors', async ({ restClient }) => { @@ -2664,38 +2541,6 @@ test.suite('validate Local API', { config: './config.ts' }, () => { expect(afterValidation.title).toBe('Stored global title') }) - test('should return valid global validation without persisting it', async ({ restClient }) => { - const response = await restClient.POST( - `/globals/${validationGlobalSlug}/validate?locale=en`, - { - body: JSON.stringify({ - summary: 'candidate summary', - }), - }, - ) - - expect(response.status).toBe(200) - await expect(response.json()).resolves.toEqual({ - errors: [], - valid: true, - }) - }) - - test('should deny collection REST validation when explicit validate access denies it', async ({ - restClient, - }) => { - const response = await restClient.POST( - `/${validationDeniedCollectionSlug}/validate?locale=en`, - { - body: JSON.stringify({ - title: 'Candidate title', - }), - }, - ) - - expect(response.status).toBe(403) - }) - test('should keep body control-shaped fields as data without changing trusted access inputs', async ({ payload, restClient, @@ -2719,48 +2564,50 @@ test.suite('validate Local API', { config: './config.ts' }, () => { ) } - const deniedResponse = await restClient.POST( - `/${validationCollectionSlug}/validate?locale=en`, - { - body: JSON.stringify({ - context: { allowValidation: true }, - operation: 'validate', - overrideAccess: true, - req: { + try { + const deniedResponse = await restClient.POST( + `/${validationCollectionSlug}/validate?locale=en`, + { + body: JSON.stringify({ context: { allowValidation: true }, operation: 'validate', + overrideAccess: true, + req: { + context: { allowValidation: true }, + operation: 'validate', + user: { email: 'trusted@example.com' }, + }, + summary: 'candidate summary', + title: 'Candidate title', user: { email: 'trusted@example.com' }, - }, - summary: 'candidate summary', - title: 'Candidate title', - user: { email: 'trusted@example.com' }, - }), - }, - ) - - collection.config.access.validate = validate + }), + }, + ) - expect(deniedResponse.status).toBe(403) - expect(accessRequests).toEqual([ - { - context: {}, - data: { - context: { allowValidation: true }, - operation: 'validate', - overrideAccess: true, - req: { + expect(deniedResponse.status).toBe(403) + expect(accessRequests).toEqual([ + { + context: {}, + data: { context: { allowValidation: true }, operation: 'validate', + overrideAccess: true, + req: { + context: { allowValidation: true }, + operation: 'validate', + user: { email: 'trusted@example.com' }, + }, + summary: 'candidate summary', + title: 'Candidate title', user: { email: 'trusted@example.com' }, }, - summary: 'candidate summary', - title: 'Candidate title', - user: { email: 'trusted@example.com' }, + operation: 'validate', + user: null, }, - operation: 'validate', - user: null, - }, - ]) + ]) + } finally { + collection.config.access.validate = validate + } }) test('should validate each requested locale independently when sibling localized fields are omitted', async ({ @@ -2933,26 +2780,6 @@ test.suite('validate Local API', { config: './config.ts' }, () => { ) }) - test('should return a valid result for a valid collection create candidate', async ({ - restClient, - }) => { - const query = `mutation { - validateValidationWriteTarget(data: { title: "GraphQL candidate" }) { - valid - errors { - path - message - } - } - }` - - const { data } = await restClient - .GRAPHQL_POST({ body: JSON.stringify({ query }) }) - .then((res) => res.json()) - - expect(data.validateValidationWriteTarget).toEqual({ errors: [], valid: true }) - }) - test('should validate a collection create candidate with a custom ID', async ({ restClient, }) => { @@ -3135,84 +2962,24 @@ test.suite('validate Local API', { config: './config.ts' }, () => { }) }) - test('should reject an update that reuses the validation request before a row is written', async ({ - payload, - }) => { - const target = await createWriteTarget({ payload }) - - await expect( - runWriteAttempt({ payload, targetID: target.id, writeAttempt: 'update' }), - ).rejects.toThrow('Payload writes are not allowed during validation') - - await expect( - payload.findByID({ - id: target.id, - collection: writeTargetsSlug, - overrideAccess: true, - }), - ).resolves.toMatchObject({ - title: 'stored target', - }) - }) - - test('should reject a bulk update that reuses the validation request before a row is written', async ({ - payload, - }) => { - const target = await createWriteTarget({ payload }) - - await expect( - runWriteAttempt({ payload, targetID: target.id, writeAttempt: 'updateMany' }), - ).rejects.toThrow('Payload writes are not allowed during validation') - - await expect( - payload.findByID({ - id: target.id, - collection: writeTargetsSlug, - overrideAccess: true, - }), - ).resolves.toMatchObject({ - title: 'stored target', - }) - }) - - test('should reject a delete that reuses the validation request before a row is removed', async ({ - payload, - }) => { - const target = await createWriteTarget({ payload }) - - await expect( - runWriteAttempt({ payload, targetID: target.id, writeAttempt: 'delete' }), - ).rejects.toThrow('Payload writes are not allowed during validation') - - await expect( - payload.findByID({ - id: target.id, - collection: writeTargetsSlug, - overrideAccess: true, - }), - ).resolves.toMatchObject({ - title: 'stored target', - }) - }) - - test('should reject a bulk delete that reuses the validation request before a row is removed', async ({ - payload, - }) => { - const target = await createWriteTarget({ payload }) + test('should reject document writes that reuse the validation request', async ({ payload }) => { + for (const writeAttempt of ['update', 'updateMany', 'delete', 'deleteMany'] as const) { + const target = await createWriteTarget({ payload }) - await expect( - runWriteAttempt({ payload, targetID: target.id, writeAttempt: 'deleteMany' }), - ).rejects.toThrow('Payload writes are not allowed during validation') + await expect( + runWriteAttempt({ payload, targetID: target.id, writeAttempt }), + ).rejects.toThrow('Payload writes are not allowed during validation') - await expect( - payload.findByID({ - id: target.id, - collection: writeTargetsSlug, - overrideAccess: true, - }), - ).resolves.toMatchObject({ - title: 'stored target', - }) + await expect( + payload.findByID({ + id: target.id, + collection: writeTargetsSlug, + overrideAccess: true, + }), + ).resolves.toMatchObject({ + title: 'stored target', + }) + } }) test('should reject a global update that reuses the validation request before data is written', async ({ @@ -3509,60 +3276,18 @@ test.suite('validate Local API', { config: './config.ts' }, () => { } }) - test('should reject a logout that reuses the validation request before a session is removed', async ({ - payload, - }) => { - const usersBefore = await payload.count({ collection: 'users', overrideAccess: true }) - - await expect(runWriteAttempt({ payload, writeAttempt: 'logout' })).rejects.toThrow( - 'Payload writes are not allowed during validation', - ) - - expect(await payload.count({ collection: 'users', overrideAccess: true })).toEqual( - usersBefore, - ) - }) - - test('should reject a refresh that reuses the validation request before a session is written', async ({ - payload, - }) => { - const usersBefore = await payload.count({ collection: 'users', overrideAccess: true }) - - await expect(runWriteAttempt({ payload, writeAttempt: 'refresh' })).rejects.toThrow( - 'Payload writes are not allowed during validation', - ) - - expect(await payload.count({ collection: 'users', overrideAccess: true })).toEqual( - usersBefore, - ) - }) - - test('should reject a reset password that reuses the validation request before a password is written', async ({ - payload, - }) => { - const usersBefore = await payload.count({ collection: 'users', overrideAccess: true }) - - await expect(runWriteAttempt({ payload, writeAttempt: 'resetPassword' })).rejects.toThrow( - 'Payload writes are not allowed during validation', - ) + test('should reject auth writes that reuse the validation request', async ({ payload }) => { + for (const writeAttempt of ['logout', 'refresh', 'resetPassword', 'verifyEmail'] as const) { + const usersBefore = await payload.count({ collection: 'users', overrideAccess: true }) - expect(await payload.count({ collection: 'users', overrideAccess: true })).toEqual( - usersBefore, - ) - }) - - test('should reject a verify email that reuses the validation request before a user is written', async ({ - payload, - }) => { - const usersBefore = await payload.count({ collection: 'users', overrideAccess: true }) - - await expect(runWriteAttempt({ payload, writeAttempt: 'verifyEmail' })).rejects.toThrow( - 'Payload writes are not allowed during validation', - ) + await expect(runWriteAttempt({ payload, writeAttempt })).rejects.toThrow( + 'Payload writes are not allowed during validation', + ) - expect(await payload.count({ collection: 'users', overrideAccess: true })).toEqual( - usersBefore, - ) + expect(await payload.count({ collection: 'users', overrideAccess: true })).toEqual( + usersBefore, + ) + } }) }) }) From e585754167eede0158ad753fef17018223c80962 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Fri, 2 Oct 2026 13:58:48 +0100 Subject: [PATCH 03/12] chore: address validation review feedback --- docs/hooks/collections.mdx | 10 ++-- docs/hooks/fields.mdx | 52 +++++++++++---------- docs/hooks/globals.mdx | 10 +++- docs/hooks/overview.mdx | 9 ++-- docs/migration-guide/v4.mdx | 25 ++++++++++ docs/validation/overview.mdx | 42 +++++++---------- packages/payload/src/auth/withBaseAccess.ts | 13 ++++-- 7 files changed, 99 insertions(+), 62 deletions(-) diff --git a/docs/hooks/collections.mdx b/docs/hooks/collections.mdx index 3e06bee621e..ce27a90255c 100644 --- a/docs/hooks/collections.mdx +++ b/docs/hooks/collections.mdx @@ -90,7 +90,9 @@ The following arguments are provided to the `beforeOperation` hook: ### beforeValidate -Runs during the `create` and `update` operations. This hook allows you to add or format data before the incoming data is validated server-side. +Runs during the `create`, `update`, and `validate` operations. This hook allows you to add or format data before the incoming data is validated server-side. + +On-demand validation runs this hook with `operation: 'validate'` without saving the candidate. Use the `operation` argument to skip external calls and other side effects that should only run when data is saved. Please do note that this does not run before client-side validation. If you render a custom field component in your front-end and provide it with a `validate` function, the order that validations will run in is: @@ -133,7 +135,9 @@ The following arguments are provided to the `beforeValidate` hook: ### beforeChange -Immediately before validation, beforeChange hooks will run during create and update operations. At this stage, the data should be treated as unvalidated user input. There is no guarantee that required fields exist or that fields are in the correct format. As such, using this data for side effects requires manual validation. You can optionally modify the shape of the data to be saved. +Immediately before validation, beforeChange hooks run during `create`, `update`, and `validate` operations. At this stage, treat the data as unvalidated user input. Required fields can be missing, and fields can have an incorrect format. You can modify the data before Payload validates it. + +On-demand validation runs this hook with `operation: 'validate'` but does not run `afterChange`. Use the `operation` argument to skip external calls and other side effects that should only run when data is saved. ```ts import type { CollectionBeforeChangeHook } from 'payload' @@ -141,7 +145,7 @@ import type { CollectionBeforeChangeHook } from 'payload' export const requireTitleOnUpdate: CollectionBeforeChangeHook = async ({ data, // Partial — changed fields only originalDoc, // Full doc before changes (defined on update) - operation, // 'create' | 'update' + operation, // 'create' | 'update' | 'validate' }) => { // Need the id? Don't expect it in `data`. const id = operation === 'update' ? originalDoc.id : undefined diff --git a/docs/hooks/fields.mdx b/docs/hooks/fields.mdx index e74e71228d7..0a854ef654d 100644 --- a/docs/hooks/fields.mdx +++ b/docs/hooks/fields.mdx @@ -56,38 +56,40 @@ const FieldWithHooks: Field = { The following arguments are provided to all Field Hooks: -| Option | Description | -| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`collection`** | The [Collection](../configuration/collections) in which this Hook is running against. If the field belongs to a Global, this will be `null`. | -| **`context`** | Custom context passed between Hooks. [More details](./context). | -| **`data`** | In the `afterRead` hook this is the full Document. In the `create` and `update` operations, this is the incoming data passed through the operation. | -| **`field`** | The [Field](../fields/overview) which the Hook is running against. | -| **`findMany`** | Boolean to denote if this hook is running against finding one, or finding many within the `afterRead` hook. | -| **`global`** | The [Global](../configuration/globals) in which this Hook is running against. If the field belongs to a Collection, this will be `null`. | -| **`operation`** | The name of the operation that this hook is running within. Useful within `beforeValidate`, `beforeChange`, and `afterChange` hooks to differentiate between `create` and `update` operations. | -| **`originalDoc`** | In the `update` operation, this is the Document before changes were applied. In the `afterChange` hook, this is the resulting Document. | -| **`overrideAccess`** | A boolean to denote if the current operation is overriding [Access Control](../access-control/overview). | -| **`path`** | The path to the [Field](../fields/overview) in the schema. | -| **`previousDoc`** | In the `afterChange` Hook, this is the Document before changes were applied. | -| **`previousSiblingDoc`** | The sibling data of the Document before changes being applied, only in `beforeChange` and `afterChange` hook. | -| **`previousValue`** | The previous value of the field, before changes, only in `beforeChange` and `afterChange` hooks. | -| **`req`** | The [Web Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) object. This is mocked for [Local API](../local-api/overview) operations. | -| **`schemaPath`** | The path of the [Field](../fields/overview) in the schema. | -| **`siblingData`** | The data of sibling fields adjacent to the field that the Hook is running against. | -| **`siblingDocWithLocales`** | The sibling data of the Document with all [Locales](../configuration/localization). | -| **`siblingFields`** | The sibling fields of the field which the hook is running against. | -| **`value`** | The value of the [Field](../fields/overview). | +| Option | Description | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`collection`** | The [Collection](../configuration/collections) in which this Hook is running against. If the field belongs to a Global, this will be `null`. | +| **`context`** | Custom context passed between Hooks. [More details](./context). | +| **`data`** | In the `afterRead` hook this is the full Document. In the `create` and `update` operations, this is the incoming data passed through the operation. | +| **`field`** | The [Field](../fields/overview) which the Hook is running against. | +| **`findMany`** | Boolean to denote if this hook is running against finding one, or finding many within the `afterRead` hook. | +| **`global`** | The [Global](../configuration/globals) in which this Hook is running against. If the field belongs to a Collection, this will be `null`. | +| **`operation`** | The name of the operation that this hook is running within. Use it to differentiate between `create`, `update`, and `validate` operations. | +| **`originalDoc`** | In the `update` operation, this is the Document before changes were applied. In the `afterChange` hook, this is the resulting Document. | +| **`overrideAccess`** | A boolean to denote if the current operation is overriding [Access Control](../access-control/overview). | +| **`path`** | The path to the [Field](../fields/overview) in the schema. | +| **`previousDoc`** | In the `afterChange` Hook, this is the Document before changes were applied. | +| **`previousSiblingDoc`** | The sibling data of the Document before changes being applied, only in `beforeChange` and `afterChange` hook. | +| **`previousValue`** | The previous value of the field, before changes, only in `beforeChange` and `afterChange` hooks. | +| **`req`** | The [Web Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) object. This is mocked for [Local API](../local-api/overview) operations. | +| **`schemaPath`** | The path of the [Field](../fields/overview) in the schema. | +| **`siblingData`** | The data of sibling fields adjacent to the field that the Hook is running against. | +| **`siblingDocWithLocales`** | The sibling data of the Document with all [Locales](../configuration/localization). | +| **`siblingFields`** | The sibling fields of the field which the hook is running against. | +| **`value`** | The value of the [Field](../fields/overview). | **Tip:** It's a good idea to conditionally scope your logic based on which operation is executing. For example, if you are writing a `beforeChange` hook, you may want to perform different logic based on if the current `operation` is - `create` or `update`. + `create`, `update`, or `validate`. ### beforeValidate -Runs during the `create` and `update` operations. This hook allows you to add or format data before the incoming data is validated server-side. +Runs during the `create`, `update`, and `validate` operations. This hook allows you to add or format data before the incoming data is validated server-side. + +On-demand validation runs this hook with `operation: 'validate'` without saving the candidate. Use the `operation` argument to skip external calls and other side effects that should only run when data is saved. Please do note that this does not run before client-side validation. If you render a custom field component in your front-end and provide it with a `validate` function, the order that validations will run in is: @@ -118,7 +120,9 @@ stored in a consistent format in the database. ### beforeChange -Immediately before validation, beforeChange hooks will run during create and update operations. At this stage, the data should be treated as unvalidated user input. There is no guarantee that required fields exist or that fields are in the correct format. As such, using this data for side effects requires manual validation. You can optionally modify the shape of the data to be saved. +Immediately before validation, beforeChange hooks run during `create`, `update`, and `validate` operations. At this stage, treat the data as unvalidated user input. Required fields can be missing, and fields can have an incorrect format. You can modify the value before Payload validates it. + +On-demand validation runs this hook with `operation: 'validate'` but does not run `afterChange`. Use the `operation` argument to skip external calls and other side effects that should only run when data is saved. ```ts import type { Field } from 'payload' diff --git a/docs/hooks/globals.mdx b/docs/hooks/globals.mdx index 1307dfa520e..308e17e708c 100644 --- a/docs/hooks/globals.mdx +++ b/docs/hooks/globals.mdx @@ -76,7 +76,9 @@ The following arguments are provided to the `beforeOperation` hook: ### beforeValidate -Runs during the `update` operation. This hook allows you to add or format data before the incoming data is validated server-side. +Runs during the `update` and `validate` operations. This hook allows you to add or format data before the incoming data is validated server-side. + +On-demand validation runs this hook with `operation: 'validate'` without saving the candidate. Use the `operation` argument to skip external calls and other side effects that should only run when data is saved. Please do note that this does not run before client-side validation. If you render a custom field component in your front-end and provide it with a `validate` function, the order that validations will run in is: @@ -104,6 +106,7 @@ The following arguments are provided to the `beforeValidate` hook: | **`global`** | The [Global](../configuration/globals) in which this Hook is running against. | | **`context`** | Custom context passed between Hooks. [More details](./context). | | **`data`** | The incoming data passed through the operation. | +| **`operation`** | The name of the operation that this hook is running within. | | **`originalDoc`** | The full document before changes are applied. Present on updates; undefined on creates. Use this to read the document id and any unchanged fields. | | **`req`** | The [Web Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) object. This is mocked for [Local API](../local-api/overview) operations. | @@ -116,7 +119,9 @@ The following arguments are provided to the `beforeValidate` hook: ### beforeChange -Immediately following validation, `beforeChange` hooks will run within the `update` operation. At this stage, you can be confident that the data that will be saved to the document is valid in accordance to your field validations. You can optionally modify the shape of data to be saved. +Immediately before field validation, `beforeChange` hooks run during the `update` and `validate` operations. At this stage, treat the data as unvalidated user input. Required fields can be missing, and fields can have an incorrect format. You can modify the data before Payload validates it. + +On-demand validation runs this hook with `operation: 'validate'` but does not run `afterChange`. Use the `operation` argument to skip external calls and other side effects that should only run when data is saved. ```ts import type { GlobalBeforeChangeHook } from 'payload' @@ -138,6 +143,7 @@ The following arguments are provided to the `beforeChange` hook: | **`global`** | The [Global](../configuration/globals) in which this Hook is running against. | | **`context`** | Custom context passed between hooks. [More details](./context). | | **`data`** | The incoming data passed through the operation. | +| **`operation`** | The name of the operation that this hook is running within. | | **`originalDoc`** | The full document before changes are applied. Present on updates; undefined on creates. Use this to read the document id and any unchanged fields. | | **`req`** | The [Web Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) object. This is mocked for [Local API](../local-api/overview) operations. | diff --git a/docs/hooks/overview.mdx b/docs/hooks/overview.mdx index af050454e34..a3e57df691a 100644 --- a/docs/hooks/overview.mdx +++ b/docs/hooks/overview.mdx @@ -180,14 +180,17 @@ For example, the `read` operation runs on every read request, so avoid putting e } ``` -Instead, you might want to use a `beforeChange` or `afterChange` hook, which only runs when a document is created or updated. +Instead, use `afterChange` for work that must run only after Payload saves a document. The `beforeChange` hook also runs during on-demand validation. ```ts { hooks: { beforeChange: [ - async ({ context }) => { - // This is more acceptable here, although still should be mindful of performance + async ({ context, operation }) => { + if (operation === 'validate') { + return + } + await doSomethingExpensive() // ... }, diff --git a/docs/migration-guide/v4.mdx b/docs/migration-guide/v4.mdx index 86735221b3e..bcfe5be560e 100644 --- a/docs/migration-guide/v4.mdx +++ b/docs/migration-guide/v4.mdx @@ -156,6 +156,31 @@ Run a typecheck after the codemod. Three patterns it cannot resolve: `payload.jobs.queue`, `run`, `runByID`, `cancel`, and `cancelByID` now also default `overrideAccess` to `false`. The option remains optional, but omitting it runs the corresponding `jobs.access.queue`, `jobs.access.run`, or `jobs.access.cancel` function. The codemod adds `overrideAccess: true` to these calls—including zero-argument `payload.jobs.run()` calls—to preserve Payload 3 behavior for review. +### On-demand validation adds a `validate` operation + +The `Operation` type now includes `validate`. The `operation` argument for collection, global, and field `beforeValidate` and `beforeChange` hooks also includes `validate`. + +On-demand validation runs these hooks without saving the candidate. Update exhaustive operation checks and skip external side effects that should only run during `create` or `update`: + +```ts +import type { CollectionBeforeChangeHook } from 'payload' + +const syncCustomer: CollectionBeforeChangeHook = async ({ + data, + operation, +}) => { + if (operation === 'validate') { + return data + } + + await syncCustomerWithStripe(data) + + return data +} +``` + +Sanitized collection and global access types now require a `validate` key. Payload supplies this key during configuration sanitization and uses `update` access by default. Code that constructs a sanitized access object directly must add the key. + ### Collection and Global Access Control callbacks receive `slug` Collection and Global Access Control callback arguments now include the document's `slug`. This additive property is unlikely to affect most projects, but code that manually invokes access callbacks or `executeAccess` must now pass `slug`. Code that validates, enumerates, or compares the exact callback argument shape may also need updating. diff --git a/docs/validation/overview.mdx b/docs/validation/overview.mdx index 3c3cc669cee..474ccf827f9 100644 --- a/docs/validation/overview.mdx +++ b/docs/validation/overview.mdx @@ -6,11 +6,10 @@ desc: Validate collection and global document candidates without saving them. keywords: validation, local api, rest api, localization, publishing, access control, hooks, drafts --- -On-demand validation checks a collection or global document candidate without saving the candidate, -creating a version, or writing files. Use it for workflow readiness checks, multi-locale review, and -validation before a custom publish flow. +Use on-demand validation to check collection or global data without saving it. Payload runs access +control, hooks, and field validation without creating a document, version, or file. -Field validation failures are returned as a result: +Field validation failures are returned in the result: ```ts import type { ValidationFieldError } from 'payload' @@ -21,19 +20,15 @@ type ValidationResult = { } ``` -Every error has a `path` and `message`. Errors from localized validation passes also set `locale`; -non-localized validation may omit it. Optional field label and table metadata may be present. - -Access denials, missing documents, invalid arguments, and non-validation errors thrown by hooks still -throw normally. +Every error has a `path` and `message`. Localized errors also set `locale`. Access denials, missing +documents, invalid arguments, and other hook errors throw normally. ## Local API -Use `payload.validate()` for collections and `payload.validateGlobal()` for globals. A collection -call without `id` validates a create candidate and requires `data`. For collection by-ID and global -validation, the stored main or published document is the base by default. Set `draft: true` to use -the newest available draft version instead, falling back to the main document when no draft exists. -Optional, partial `data` is merged over that base. +Use `payload.validate()` for collections and `payload.validateGlobal()` for globals. Omit `id` and +provide `data` to validate a collection create candidate. For stored documents and globals, partial +`data` is merged with the main document. Set `draft: true` to use the newest draft, with the main +document as a fallback. ```ts // Validate collection create data in one locale @@ -268,18 +263,13 @@ export const SiteSettings: GlobalConfig = { } ``` -Whether configured directly or inherited from `update`, collection, global, and field access -functions all receive the first-class -`req.operation === 'validate'`. Field `beforeValidate` and `beforeChange` hooks, field validators, -and collection/global `beforeValidate` and `beforeChange` hooks also receive -`operation: 'validate'`. Payload never simulates `'create'` or `'update'` as the access operation. - -When `validate` access returns a `where` constraint and a document exists but doesn't match it, -validation throws `Forbidden` rather than treating the document as absent. Without this, candidate -data could validate against an empty base as though the restricted document didn't exist yet. -Collections already apply this through their existing `update`-by-ID access checks; globals apply -the same rule for validation specifically, even though a global's `find` and `update` stay silent -about a document being restricted rather than absent. +Collection, global, and field access functions receive `req.operation === 'validate'`. Field +validators and `beforeValidate` and `beforeChange` hooks receive `operation: 'validate'`. Payload +does not report the operation as `create` or `update`. + +When `validate` access returns a `where` constraint, validation throws `Forbidden` if the stored +document does not match it. Collection create validation requires boolean access because there is no +stored document to test against the constraint. A collection create-candidate validation has no stored document against which Payload can evaluate a `where` constraint. Its `validate` access must therefore return a boolean. Payload throws diff --git a/packages/payload/src/auth/withBaseAccess.ts b/packages/payload/src/auth/withBaseAccess.ts index 4f0a13e29ef..8b07a5a7428 100644 --- a/packages/payload/src/auth/withBaseAccess.ts +++ b/packages/payload/src/auth/withBaseAccess.ts @@ -32,14 +32,19 @@ export const withBaseAccess = (options: Args): Access => { slug: options.slug, } const { baseAccess } = args.req.payload.config - const baseAccessFunction = - options.entityType === 'collection' - ? options.operation === 'validate' + let baseAccessFunction: Access | undefined + + if (options.entityType === 'collection') { + baseAccessFunction = + options.operation === 'validate' ? (baseAccess?.collections?.validate ?? baseAccess?.collections?.update) : baseAccess?.collections?.[options.operation] - : options.operation === 'validate' + } else { + baseAccessFunction = + options.operation === 'validate' ? (baseAccess?.globals?.validate ?? baseAccess?.globals?.update) : baseAccess?.globals?.[options.operation] + } if (!baseAccessFunction) { return documentAccess(accessArgs) From e4852db84c389f7ffa9058babccd7b326d86b51a Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Fri, 2 Oct 2026 14:18:23 +0100 Subject: [PATCH 04/12] chore: simplify validation documentation --- docs/validation/overview.mdx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/validation/overview.mdx b/docs/validation/overview.mdx index 474ccf827f9..a0b60d009bc 100644 --- a/docs/validation/overview.mdx +++ b/docs/validation/overview.mdx @@ -6,8 +6,8 @@ desc: Validate collection and global document candidates without saving them. keywords: validation, local api, rest api, localization, publishing, access control, hooks, drafts --- -Use on-demand validation to check collection or global data without saving it. Payload runs access -control, hooks, and field validation without creating a document, version, or file. +You can validate a collection document or global without writing it to the database. Call +`payload.validate()` for a collection or `payload.validateGlobal()` for a global. Field validation failures are returned in the result: @@ -25,10 +25,9 @@ documents, invalid arguments, and other hook errors throw normally. ## Local API -Use `payload.validate()` for collections and `payload.validateGlobal()` for globals. Omit `id` and -provide `data` to validate a collection create candidate. For stored documents and globals, partial -`data` is merged with the main document. Set `draft: true` to use the newest draft, with the main -document as a fallback. +Omit `id` and provide `data` to validate a collection create candidate. For stored documents and +globals, partial `data` is merged with the main document. Set `draft: true` to use the newest draft, +with the main document as a fallback. ```ts // Validate collection create data in one locale From c037d4f3e45fdb98170d6ff9e572b636e8f670fd Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Fri, 2 Oct 2026 16:07:47 +0100 Subject: [PATCH 05/12] chore: validate configured unique constraints Written with AI --- docs/validation/overview.mdx | 19 +++ .../src/resolvers/collections/validate.ts | 5 +- .../graphql/src/resolvers/globals/validate.ts | 5 +- packages/payload/src/admin/RichText.ts | 10 +- .../collections/operations/local/validate.ts | 9 +- .../utilities/validateUniqueConstraints.ts | 137 ++++++++++++++++++ .../src/collections/operations/validate.ts | 19 +++ packages/payload/src/config/types.ts | 4 +- .../src/fields/hooks/beforeValidate/index.ts | 4 +- .../fields/hooks/beforeValidate/promise.ts | 9 +- .../hooks/beforeValidate/traverseFields.ts | 4 +- packages/payload/src/globals/config/types.ts | 14 +- .../src/globals/operations/local/validate.ts | 16 +- packages/payload/src/types/index.ts | 1 + .../utilities/documentMatchingWhereExists.ts | 50 +++++++ .../payload/src/utilities/fieldValueExists.ts | 38 +---- .../src/utilities/parseValidationLocale.ts | 50 +------ .../src/features/typesServer.ts | 5 +- test/types/types.spec.ts | 5 + test/validate/collections.ts | 32 ++++ test/validate/int.spec.ts | 101 +++++++++++++ test/validate/shared.ts | 1 + 22 files changed, 429 insertions(+), 109 deletions(-) create mode 100644 packages/payload/src/collections/operations/utilities/validateUniqueConstraints.ts create mode 100644 packages/payload/src/utilities/documentMatchingWhereExists.ts diff --git a/docs/validation/overview.mdx b/docs/validation/overview.mdx index a0b60d009bc..c99841f6fda 100644 --- a/docs/validation/overview.mdx +++ b/docs/validation/overview.mdx @@ -110,6 +110,25 @@ return { ready: true } The Local API enforces access control by default. Set `overrideAccess: true` only for trusted internal calls that must skip access control. +### Database uniqueness checks + +Collection validation checks fields configured with `unique: true` and collection compound indexes +configured with `unique: true`. It checks the complete candidate after field hooks and validators +run. For partial updates, Payload combines the candidate with the stored document and excludes that +document from the database query. + +These checks apply across the collection, even if the caller cannot read a conflicting document. +They only cover constraints declared in the Payload collection configuration. They do not inspect +indexes created directly in the database or by a migration. Upload `filenameCompoundIndex` +constraints are also not checked in this initial implementation. + +Payload skips uniqueness checks for `null` and missing values. It checks a compound index only when +every indexed field has a non-null value. Database adapters have different rules for these values, +so the database remains authoritative for those candidates. Checks use the main collection and do +not include draft-only versions. The check is also not a lock: another request can write the same +value after validation succeeds. Applications must still handle a uniqueness error from the later +write. + ## REST API Validation endpoints use `POST`. Send candidate data as a JSON object and select locales with the diff --git a/packages/graphql/src/resolvers/collections/validate.ts b/packages/graphql/src/resolvers/collections/validate.ts index 8ee5317d5de..f4b248b6458 100644 --- a/packages/graphql/src/resolvers/collections/validate.ts +++ b/packages/graphql/src/resolvers/collections/validate.ts @@ -1,7 +1,6 @@ import type { Collection, CollectionSlug, - PayloadRequest, RequiredDataFromCollectionSlug, ValidationResult, } from 'payload' @@ -19,9 +18,7 @@ export type Resolver = ( id?: number | string locale?: string }, - context: { - req: PayloadRequest - }, + context: Context, ) => Promise /** diff --git a/packages/graphql/src/resolvers/globals/validate.ts b/packages/graphql/src/resolvers/globals/validate.ts index 0c3167dc25a..a24479930c8 100644 --- a/packages/graphql/src/resolvers/globals/validate.ts +++ b/packages/graphql/src/resolvers/globals/validate.ts @@ -1,7 +1,6 @@ import type { DataFromGlobalSlug, GlobalSlug, - PayloadRequest, SanitizedGlobalConfig, ValidationResult, } from 'payload' @@ -18,9 +17,7 @@ export type Resolver = ( draft?: boolean locale?: string }, - context: { - req: PayloadRequest - }, + context: Context, ) => Promise /** diff --git a/packages/payload/src/admin/RichText.ts b/packages/payload/src/admin/RichText.ts index 97e0f7c1bd5..0a8d9f3848c 100644 --- a/packages/payload/src/admin/RichText.ts +++ b/packages/payload/src/admin/RichText.ts @@ -13,7 +13,13 @@ import type { } from '../fields/config/types.js' import type { SanitizedGlobalConfig } from '../globals/config/types.js' import type { RequestContext, TypedFallbackLocale } from '../index.js' -import type { FieldOperation, JsonObject, PayloadRequest, PopulateType } from '../types/index.js' +import type { + BeforeValidateOperation, + FieldOperation, + JsonObject, + PayloadRequest, + PopulateType, +} from '../types/index.js' import type { FieldsToJSONSchemaArgs } from '../utilities/configToJSONSchema.js' import type { RichTextFieldClientProps, RichTextFieldServerProps } from './fields/RichText.js' import type { FieldDiffClientProps, FieldDiffServerProps, FieldSchemaMap } from './types.js' @@ -77,7 +83,7 @@ export type BeforeValidateRichTextHookArgs< TSiblingData = any, > = { /** A string relating to which operation the field type is currently executing within. */ - operation: 'create' | 'update' | 'validate' + operation: BeforeValidateOperation overrideAccess?: boolean /** The sibling data of the document before changes being applied. */ previousSiblingDoc?: TSiblingData diff --git a/packages/payload/src/collections/operations/local/validate.ts b/packages/payload/src/collections/operations/local/validate.ts index 3249fbed31a..a131d112331 100644 --- a/packages/payload/src/collections/operations/local/validate.ts +++ b/packages/payload/src/collections/operations/local/validate.ts @@ -6,6 +6,7 @@ import type { CollectionSlug, Payload, RequestContext, + SharedLocalAPIOptions, User, ValidationFieldError, } from '../../../index.js' @@ -53,11 +54,6 @@ type BaseOptions = { * for projects without localization. */ locale: ValidationLocaleSelector - /** - * Skip collection and field access control. - * @default false - */ - overrideAccess?: boolean /** * An existing request to reuse for user, locale, and context. */ @@ -66,7 +62,8 @@ type BaseOptions = { * The user used by access control when `overrideAccess` is `false`. */ user?: null | User -} & DraftFlagFromCollectionSlug +} & DraftFlagFromCollectionSlug & + Pick /** * Options for validating a collection document without persisting it. diff --git a/packages/payload/src/collections/operations/utilities/validateUniqueConstraints.ts b/packages/payload/src/collections/operations/utilities/validateUniqueConstraints.ts new file mode 100644 index 00000000000..30b11a4f725 --- /dev/null +++ b/packages/payload/src/collections/operations/utilities/validateUniqueConstraints.ts @@ -0,0 +1,137 @@ +import type { ValidationFieldError } from '../../../errors/ValidationError.js' +import type { JsonObject, PayloadRequest } from '../../../types/index.js' +import type { SanitizedCollectionConfig } from '../../config/types.js' + +import { ValidationError } from '../../../errors/index.js' +import { fieldAffectsData } from '../../../fields/config/types.js' +import { documentMatchingWhereExists } from '../../../utilities/documentMatchingWhereExists.js' +import { fieldValueExists } from '../../../utilities/fieldValueExists.js' +import { getObjectDotNotation } from '../../../utilities/getObjectDotNotation.js' +import { traverseFields } from '../../../utilities/traverseFields.js' + +type Args = { + collection: SanitizedCollectionConfig + data: JsonObject + id?: number | string + req: PayloadRequest +} + +type UniqueFieldValue = { + field: string + value: unknown +} & Pick + +export const validateUniqueConstraints = async ({ + id, + collection, + data, + req, +}: Args): Promise => { + const uniqueFieldValues: UniqueFieldValue[] = [] + + traverseFields({ + callback: ({ field, parentPath, ref }) => { + if (!fieldAffectsData(field) || !field.unique || !ref || typeof ref !== 'object') { + return + } + + const value = ref[field.name as keyof typeof ref] + + if (value === null || typeof value === 'undefined') { + return + } + + uniqueFieldValues.push({ + field: `${parentPath}${field.name}`, + label: field.label || undefined, + path: `${parentPath}${field.name}`, + value, + }) + }, + config: req.payload.config, + fields: collection.fields, + fillEmpty: false, + ref: data, + }) + + const conflicts = await Promise.all( + uniqueFieldValues.map(async (uniqueFieldValue) => ({ + ...uniqueFieldValue, + exists: await fieldValueExists({ + id, + collection: collection.slug, + field: uniqueFieldValue.field, + locale: req.locale ?? undefined, + overrideAccess: true, + req, + value: uniqueFieldValue.value, + }), + })), + ) + const errors: ValidationFieldError[] = conflicts + .filter(({ exists }) => exists) + .map(({ label, path }) => ({ + label, + message: req.t('error:valueMustBeUnique'), + path, + })) + + const uniqueCompoundIndexes = collection.sanitizedIndexes.filter(({ unique }) => unique) + const compoundIndexConflicts = await Promise.all( + uniqueCompoundIndexes.map(async (index) => { + const values = index.fields.map(({ path }) => ({ + path, + value: getObjectDotNotation(data, path), + })) + + if (values.some(({ value }) => value === null || typeof value === 'undefined')) { + return null + } + + const exists = await documentMatchingWhereExists({ + id, + collection: collection.slug, + locale: req.locale ?? undefined, + overrideAccess: true, + req, + where: { + and: values.map(({ path, value }) => ({ + [path]: { + equals: value, + }, + })), + }, + }) + + return exists ? index : null + }), + ) + + for (const index of compoundIndexConflicts) { + if (!index) { + continue + } + + for (const { field, path } of index.fields) { + errors.push({ + label: field.label || undefined, + message: req.t('error:valueMustBeUnique'), + path, + }) + } + } + + const deduplicatedErrors = [...new Map(errors.map((error) => [error.path, error])).values()] + + if (deduplicatedErrors.length > 0) { + throw new ValidationError( + { + id, + collection: collection.slug, + errors: deduplicatedErrors, + req, + }, + req.t, + ) + } +} diff --git a/packages/payload/src/collections/operations/validate.ts b/packages/payload/src/collections/operations/validate.ts index f46b5250bcd..7cf6ce96ec6 100644 --- a/packages/payload/src/collections/operations/validate.ts +++ b/packages/payload/src/collections/operations/validate.ts @@ -20,6 +20,7 @@ import { deepMergeWithSourceArraysIgnoringUndefined } from '../../utilities/deep import { flattenDataByLocale } from '../../utilities/flattenDataByLocale.js' import { toValidationResult } from '../../utilities/toValidationResult.js' import { appendVersionToQueryKey } from '../../versions/drafts/appendVersionToQueryKey.js' +import { validateUniqueConstraints } from './utilities/validateUniqueConstraints.js' export type Arguments = { collection: Collection @@ -235,6 +236,8 @@ async function validateOperationWithScopedRequest( onValidationData?.(data) + let processedData = data + await beforeChange({ id, collection: collectionConfig, @@ -243,10 +246,26 @@ async function validateOperationWithScopedRequest( doc: originalDoc, docWithLocales, global: null, + onDataProcessed: (result) => { + processedData = result + }, operation: 'validate', overrideAccess, req, }) + + const validationData = deepMergeWithSourceArraysIgnoringUndefined( + originalDoc, + processedData, + ) + onValidationData?.(validationData) + + await validateUniqueConstraints({ + id, + collection: collectionConfig, + data: validationData, + req, + }) } catch (error) { return toValidationResult({ error, req }) } diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 6493faae4d1..164fb775afe 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -721,8 +721,6 @@ export type LocalizationConfigWithLabels = Prettify< } & BaseLocalizationConfig > -export type SanitizedLocale = Locale - export type SanitizedLocalizationConfig = Prettify< { /** @@ -730,7 +728,7 @@ export type SanitizedLocalizationConfig = Prettify< * @example `["en", "es", "fr", "nl", "de", "jp"]` */ localeCodes: string[] - locales: SanitizedLocale[] + locales: Locale[] } & Omit & Required> > diff --git a/packages/payload/src/fields/hooks/beforeValidate/index.ts b/packages/payload/src/fields/hooks/beforeValidate/index.ts index 33baaa2bb74..f76faf3139e 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/index.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/index.ts @@ -1,6 +1,6 @@ import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' -import type { JsonObject, PayloadRequest } from '../../../types/index.js' +import type { BeforeValidateOperation, JsonObject, PayloadRequest } from '../../../types/index.js' import { type RequestContext } from '../../../index.js' import { traverseFields } from './traverseFields.js' @@ -15,7 +15,7 @@ type Args = { global: null | SanitizedGlobalConfig id?: number | string onFieldAccess?: (args: { accessResult: boolean; path: string }) => void - operation: 'create' | 'update' | 'validate' + operation: BeforeValidateOperation overrideAccess: boolean req: PayloadRequest } diff --git a/packages/payload/src/fields/hooks/beforeValidate/promise.ts b/packages/payload/src/fields/hooks/beforeValidate/promise.ts index cdfcf04c640..1a72b03ef5d 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/promise.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/promise.ts @@ -2,7 +2,12 @@ import type { RichTextAdapter } from '../../../admin/RichText.js' import type { SanitizedCollectionConfig, TypeWithID } from '../../../collections/config/types.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' import type { RequestContext } from '../../../index.js' -import type { JsonObject, JsonValue, PayloadRequest } from '../../../types/index.js' +import type { + BeforeValidateOperation, + JsonObject, + JsonValue, + PayloadRequest, +} from '../../../types/index.js' import type { Block, Field, TabAsField } from '../../config/types.js' import { MissingEditorProp } from '../../../errors/index.js' @@ -31,7 +36,7 @@ type Args = { global: null | SanitizedGlobalConfig id?: number | string onFieldAccess?: (args: { accessResult: boolean; path: string }) => void - operation: 'create' | 'update' | 'validate' + operation: BeforeValidateOperation overrideAccess: boolean parentIndexPath: string parentIsLocalized: boolean diff --git a/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts b/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts index 6869a9c0782..c95c8dadaba 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts @@ -1,7 +1,7 @@ import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' import type { RequestContext } from '../../../index.js' -import type { JsonObject, PayloadRequest } from '../../../types/index.js' +import type { BeforeValidateOperation, JsonObject, PayloadRequest } from '../../../types/index.js' import type { Field, TabAsField } from '../../config/types.js' import { unflattenData } from '../../../utilities/unflattenData.js' @@ -24,7 +24,7 @@ type Args = { global: null | SanitizedGlobalConfig id?: number | string onFieldAccess?: (args: { accessResult: boolean; path: string }) => void - operation: 'create' | 'update' | 'validate' + operation: BeforeValidateOperation overrideAccess: boolean parentIndexPath: string /** diff --git a/packages/payload/src/globals/config/types.ts b/packages/payload/src/globals/config/types.ts index 07fa885bd01..6a46b76f4ce 100644 --- a/packages/payload/src/globals/config/types.ts +++ b/packages/payload/src/globals/config/types.ts @@ -25,7 +25,13 @@ import type { TypedGlobal, TypedGlobalSelect, } from '../../index.js' -import type { PayloadRequest, SelectIncludeType, Where, WithSelectFn } from '../../types/index.js' +import type { + FieldOperation, + PayloadRequest, + SelectIncludeType, + Where, + WithSelectFn, +} from '../../types/index.js' import type { IncomingGlobalVersions, SanitizedGlobalVersions } from '../../versions/types.js' export type DataFromGlobalSlug = TypedGlobal[TSlug] @@ -80,13 +86,15 @@ export type DraftFlagFromGlobalSlug = GeneratedTypes e draft?: boolean } +type GlobalChangeOperation = Extract + export type BeforeValidateHook = (args: { context: RequestContext data?: any /** The global which this hook is being run on */ global: SanitizedGlobalConfig /** Hook operation being performed. */ - operation: 'update' | 'validate' + operation: GlobalChangeOperation originalDoc?: any /** * Whether access control is being overridden for this operation @@ -101,7 +109,7 @@ export type BeforeChangeHook = (args: { /** The global which this hook is being run on */ global: SanitizedGlobalConfig /** Hook operation being performed. */ - operation: 'update' | 'validate' + operation: GlobalChangeOperation originalDoc?: any /** * Whether access control is being overridden for this operation diff --git a/packages/payload/src/globals/operations/local/validate.ts b/packages/payload/src/globals/operations/local/validate.ts index 8fa82107574..1eff18cf5ee 100644 --- a/packages/payload/src/globals/operations/local/validate.ts +++ b/packages/payload/src/globals/operations/local/validate.ts @@ -3,7 +3,13 @@ import type { DeepPartial } from 'ts-essentials' import { status as httpStatus } from 'http-status' import type { ValidationResult } from '../../../collections/operations/local/validate.js' -import type { GlobalSlug, Payload, RequestContext, User } from '../../../index.js' +import type { + GlobalSlug, + Payload, + RequestContext, + SharedLocalAPIOptions, + User, +} from '../../../index.js' import type { PayloadRequest } from '../../../types/index.js' import type { ValidationLocaleSelector } from '../../../utilities/resolveValidationLocales.js' import type { DataFromGlobalSlug, DraftFlagFromGlobalSlug } from '../../config/types.js' @@ -33,18 +39,14 @@ export type ValidateGlobalOptions = { * for projects without localization. */ locale: ValidationLocaleSelector - /** - * Skip global and field access control. - * @default false - */ - overrideAccess?: boolean /** An existing request to reuse for user, locale, and context. */ req?: Partial /** The global slug to validate against. */ slug: TSlug /** The user used by access control when `overrideAccess` is `false`. */ user?: null | User -} & DraftFlagFromGlobalSlug +} & DraftFlagFromGlobalSlug & + Pick type InternalValidateGlobalOptions = { /** diff --git a/packages/payload/src/types/index.ts b/packages/payload/src/types/index.ts index f5a7e568968..31a01cd5985 100644 --- a/packages/payload/src/types/index.ts +++ b/packages/payload/src/types/index.ts @@ -200,6 +200,7 @@ export type Document = any */ export type Operation = 'create' | 'delete' | 'read' | 'update' | 'validate' export type FieldOperation = Operation +export type BeforeValidateOperation = Extract export type VersionOperations = 'readVersions' export type AuthOperations = 'unlock' export type AllOperations = AuthOperations | Operation | VersionOperations diff --git a/packages/payload/src/utilities/documentMatchingWhereExists.ts b/packages/payload/src/utilities/documentMatchingWhereExists.ts new file mode 100644 index 00000000000..0cdc9e6fd54 --- /dev/null +++ b/packages/payload/src/utilities/documentMatchingWhereExists.ts @@ -0,0 +1,50 @@ +import type { DefaultDocumentIDType, Locale } from '../index.js' +import type { PayloadRequest, Where } from '../types/index.js' + +import { isolateObjectProperty } from './isolateObjectProperty.js' + +export type DocumentMatchingWhereExistsArgs = { + collection: string + /** + * When true, also matches documents whose value only exists in a draft version. A versioned + * collection keeps draft data in `_versions`, which the main-collection query — and the unique + * index — would miss. + */ + draftsEnabled?: boolean + /** Exclude this document, so a document does not conflict with itself on update. */ + id?: DefaultDocumentIDType + locale?: Locale['code'] + overrideAccess?: boolean + req: PayloadRequest + where: Where +} + +/** Whether another document in `collection` matches `where`. */ +export const documentMatchingWhereExists = async ({ + id, + collection, + draftsEnabled, + locale, + overrideAccess = false, + req, + where, +}: DocumentMatchingWhereExistsArgs): Promise => { + const queryReq = isolateObjectProperty(req, ['query', 'transactionID']) + queryReq.query = { ...req.query } + delete queryReq.transactionID + + const { docs } = await req.payload.find({ + collection, + depth: 0, + disableErrors: true, + draft: Boolean(draftsEnabled), + limit: 2, + locale: locale as Parameters[0]['locale'], + overrideAccess, + pagination: false, + req: queryReq, + where, + }) + + return docs.some((doc) => doc.id !== id) +} diff --git a/packages/payload/src/utilities/fieldValueExists.ts b/packages/payload/src/utilities/fieldValueExists.ts index cb149c883de..1250b31520a 100644 --- a/packages/payload/src/utilities/fieldValueExists.ts +++ b/packages/payload/src/utilities/fieldValueExists.ts @@ -1,24 +1,11 @@ -import type { DefaultDocumentIDType, Locale } from '../index.js' -import type { PayloadRequest } from '../types/index.js' +import type { DocumentMatchingWhereExistsArgs } from './documentMatchingWhereExists.js' -import { isolateObjectProperty } from './isolateObjectProperty.js' +import { documentMatchingWhereExists } from './documentMatchingWhereExists.js' type Args = { - collection: string - /** - * When true, also matches documents whose value only exists in a draft version. A versioned - * collection keeps draft data in `_versions`, which the main-collection query — and the unique - * index — would miss. - */ - draftsEnabled?: boolean field: string - /** Exclude this document, so a doc doesn't conflict with itself on update. */ - id?: DefaultDocumentIDType - locale?: Locale['code'] - overrideAccess?: boolean - req: PayloadRequest value: unknown -} +} & Omit /** * Whether another document in `collection` already uses `value` for `field`. @@ -38,22 +25,13 @@ export const fieldValueExists = async ({ req, value, }: Args): Promise => { - const queryReq = isolateObjectProperty(req, ['query', 'transactionID']) - queryReq.query = { ...req.query } - delete queryReq.transactionID - - const { docs } = await req.payload.find({ + return documentMatchingWhereExists({ + id, collection, - depth: 0, - disableErrors: true, - draft: Boolean(draftsEnabled), - limit: 2, - locale: locale as Parameters[0]['locale'], + draftsEnabled, + locale, overrideAccess, - pagination: false, - req: queryReq, + req, where: { [field]: { equals: value } }, }) - - return docs.some((doc) => doc.id !== id) } diff --git a/packages/payload/src/utilities/parseValidationLocale.ts b/packages/payload/src/utilities/parseValidationLocale.ts index 32b0e296302..7c308014263 100644 --- a/packages/payload/src/utilities/parseValidationLocale.ts +++ b/packages/payload/src/utilities/parseValidationLocale.ts @@ -5,33 +5,21 @@ import type { ValidationLocaleSelector } from './resolveValidationLocales.js' import { APIError } from '../errors/index.js' -type ValidationLocale = - | { - locale: TypedLocale - type: 'single' - } - | { - locales: TypedLocale[] - type: 'multiple' - } - | { - type: 'all' - } - -export function parseValidationLocale(locale: unknown): ValidationLocale { +/** + * Parses a REST `locale` query value. Repeated query parameters are represented as an array and + * `locale=all` selects all locales. + */ +export function parseValidationLocaleSelector(locale: unknown): ValidationLocaleSelector { if (typeof locale === 'string') { if (locale.length === 0) { throw new APIError('Validation requires a locale.', httpStatus.BAD_REQUEST) } if (locale === 'all') { - return { type: 'all' } + return 'all' } - return { - type: 'single', - locale: locale as TypedLocale, - } + return locale as TypedLocale } if ( @@ -43,34 +31,12 @@ export function parseValidationLocale(locale: unknown): ValidationLocale { throw new APIError('Validation requires a locale.', httpStatus.BAD_REQUEST) } - return { - type: 'multiple', - locales: locale as TypedLocale[], - } + return locale as [TypedLocale, ...TypedLocale[]] } throw new APIError('Validation requires a locale.', httpStatus.BAD_REQUEST) } -/** - * Parses a REST `locale` query value. Repeated query parameters are represented as an array and - * `locale=all` selects all locales. - */ -export function parseValidationLocaleSelector(locale: unknown): ValidationLocaleSelector { - const parsedLocale = parseValidationLocale(locale) - - switch (parsedLocale.type) { - case 'all': - return 'all' - - case 'multiple': - return parsedLocale.locales as [TypedLocale, ...TypedLocale[]] - - case 'single': - return parsedLocale.locale - } -} - /** Ensures a REST validation request body is a non-null JSON object. */ export function assertValidationData(data: unknown): asserts data is Record { if (!data || Array.isArray(data) || typeof data !== 'object') { diff --git a/packages/richtext-lexical/src/features/typesServer.ts b/packages/richtext-lexical/src/features/typesServer.ts index 810af5ce461..862d83c6f3e 100644 --- a/packages/richtext-lexical/src/features/typesServer.ts +++ b/packages/richtext-lexical/src/features/typesServer.ts @@ -10,6 +10,7 @@ import type { } from 'lexical' import type { Field, + FieldOperation, FieldSchemaMap, FieldsToJSONSchemaArgs, ImportMapGenerators, @@ -165,7 +166,7 @@ export type AfterChangeNodeHookArgs = { } export type BeforeValidateNodeHookArgs = { /** A string relating to which operation the field type is currently executing within. Useful within beforeValidate, beforeChange, and afterChange hooks to differentiate between create and update operations. */ - operation: 'create' | 'delete' | 'read' | 'update' | 'validate' + operation: FieldOperation /** The value of the node before any changes. Not available in afterRead hooks */ originalNode: T overrideAccess: boolean @@ -178,7 +179,7 @@ export type BeforeChangeNodeHookArgs = { errors: ValidationFieldError[] mergeLocaleActions: (() => Promise | void)[] /** A string relating to which operation the field type is currently executing within. Useful within beforeValidate, beforeChange, and afterChange hooks to differentiate between create and update operations. */ - operation: 'create' | 'delete' | 'read' | 'update' | 'validate' + operation: FieldOperation /** The value of the node before any changes. Not available in afterRead hooks */ originalNode: T /** diff --git a/test/types/types.spec.ts b/test/types/types.spec.ts index 07018d21624..a0d9d58cf3b 100644 --- a/test/types/types.spec.ts +++ b/test/types/types.spec.ts @@ -4,6 +4,7 @@ import type { Access, ArrayField, AuthenticatedUser, + BeforeValidateOperation, Block, BlockRowLabelClientProps, BlockRowLabelServerProps, @@ -129,6 +130,10 @@ import type { describe('Types testing', () => { describe('validate operation types', () => { + test('should expose beforeValidate operations', () => { + expect().type.toBe<'create' | 'update' | 'validate'>() + }) + test('should expose validate only to validation lifecycle types', () => { expect<{ locale?: string diff --git a/test/validate/collections.ts b/test/validate/collections.ts index 477289afeee..602a56fd78d 100644 --- a/test/validate/collections.ts +++ b/test/validate/collections.ts @@ -36,6 +36,7 @@ import { validationNonLocalizedCollectionSlug, validationPublishUploadsDir, validationPublishUploadsSlug, + validationUniqueCollectionSlug, validationUploadsDir, validationUploadsSlug, validationWhereCollectionSlug, @@ -946,6 +947,36 @@ const validationEmptyCollection: CollectionConfig = { versions: false, } +const validationUniqueCollection: CollectionConfig = { + slug: validationUniqueCollectionSlug, + fields: [ + { + name: 'uniqueValue', + type: 'text', + hooks: { + beforeChange: [({ value }) => (typeof value === 'string' ? value.trim() : value)], + }, + unique: true, + }, + { + name: 'compoundScope', + type: 'text', + }, + { + name: 'compoundValue', + type: 'text', + }, + ], + indexes: [ + { + fields: ['compoundScope', 'compoundValue'], + unique: true, + }, + ], + timestamps: false, + versions: false, +} + export const validationCollections: CollectionConfig[] = [ validationCollection, validationFallbackCollection, @@ -958,6 +989,7 @@ export const validationCollections: CollectionConfig[] = [ validationAuthCollection, validationCustomIDCollection, validationEmptyCollection, + validationUniqueCollection, { slug: writeTargetsSlug, fields: [ diff --git a/test/validate/int.spec.ts b/test/validate/int.spec.ts index cff53e27ffd..76d9bbea9f2 100644 --- a/test/validate/int.spec.ts +++ b/test/validate/int.spec.ts @@ -38,6 +38,7 @@ import { validationFallbackCollectionSlug, validationFallbackGlobalSlug, validationGlobalSlug, + validationUniqueCollectionSlug, validationUploadsDir, validationUploadsSlug, validationWhereCollectionSlug, @@ -77,6 +78,106 @@ test.suite('validate Local API', { config: './config.ts' }, () => { }) test.describe('collections', () => { + test('should report a configured unique field conflict', async ({ payload }) => { + await payload.create({ + collection: validationUniqueCollectionSlug, + data: { + uniqueValue: 'already-used', + }, + overrideAccess: true, + }) + + const result = await payload.validate({ + collection: validationUniqueCollectionSlug, + data: { + uniqueValue: ' already-used ', + }, + locale: 'en', + overrideAccess: true, + }) + + expect(result).toMatchObject({ + errors: [ + { + path: 'uniqueValue', + }, + ], + valid: false, + }) + }) + + test('should report a configured compound unique index conflict for a partial update', async ({ + payload, + }) => { + await payload.create({ + collection: validationUniqueCollectionSlug, + data: { + compoundScope: 'scope-a', + compoundValue: 'shared-value', + uniqueValue: 'first-unique-value', + }, + overrideAccess: true, + }) + const storedDocument = await payload.create({ + collection: validationUniqueCollectionSlug, + data: { + compoundScope: 'scope-b', + compoundValue: 'shared-value', + uniqueValue: 'second-unique-value', + }, + overrideAccess: true, + }) + + const result = await payload.validate({ + id: storedDocument.id, + collection: validationUniqueCollectionSlug, + data: { + compoundScope: 'scope-a', + }, + locale: 'en', + overrideAccess: true, + }) + + expect(result).toMatchObject({ + errors: [ + { + path: 'compoundScope', + }, + { + path: 'compoundValue', + }, + ], + valid: false, + }) + }) + + test('should exclude the stored document from configured uniqueness checks', async ({ + payload, + }) => { + const storedDocument = await payload.create({ + collection: validationUniqueCollectionSlug, + data: { + compoundScope: 'stored-scope', + compoundValue: 'stored-compound-value', + uniqueValue: 'stored-unique-value', + }, + overrideAccess: true, + }) + + const result = await payload.validate({ + id: storedDocument.id, + collection: validationUniqueCollectionSlug, + data: {}, + locale: 'en', + overrideAccess: true, + }) + + expect(result).toEqual({ + errors: [], + valid: true, + }) + }) + test('should use collection update access as the validate fallback', async ({ payload }) => { await expect( payload.validate({ diff --git a/test/validate/shared.ts b/test/validate/shared.ts index 5c8566e557d..2f3a225a0cc 100644 --- a/test/validate/shared.ts +++ b/test/validate/shared.ts @@ -26,6 +26,7 @@ export const validationNonLocalizedCollectionSlug = 'validation-non-localized-it export const validationAuthCollectionSlug = 'validation-auth-items' export const validationCustomIDCollectionSlug = 'validation-custom-id-items' export const validationEmptyCollectionSlug = 'validation-empty-items' +export const validationUniqueCollectionSlug = 'validation-unique-items' export const validationUploadsDir = path.resolve(dirname, 'validation-uploads') export const validationPublishUploadsDir = path.resolve(dirname, 'validation-publish-uploads') From 36f9134d370dff4f2c469b792e8bb0a7bd167ced Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Fri, 2 Oct 2026 16:18:01 +0100 Subject: [PATCH 06/12] chore: simplify uniqueness validation docs Written with AI --- docs/validation/overview.mdx | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/docs/validation/overview.mdx b/docs/validation/overview.mdx index c99841f6fda..07f0ff38741 100644 --- a/docs/validation/overview.mdx +++ b/docs/validation/overview.mdx @@ -112,22 +112,9 @@ internal calls that must skip access control. ### Database uniqueness checks -Collection validation checks fields configured with `unique: true` and collection compound indexes -configured with `unique: true`. It checks the complete candidate after field hooks and validators -run. For partial updates, Payload combines the candidate with the stored document and excludes that -document from the database query. - -These checks apply across the collection, even if the caller cannot read a conflicting document. -They only cover constraints declared in the Payload collection configuration. They do not inspect -indexes created directly in the database or by a migration. Upload `filenameCompoundIndex` -constraints are also not checked in this initial implementation. - -Payload skips uniqueness checks for `null` and missing values. It checks a compound index only when -every indexed field has a non-null value. Database adapters have different rules for these values, -so the database remains authoritative for those candidates. Checks use the main collection and do -not include draft-only versions. The check is also not a lock: another request can write the same -value after validation succeeds. Applications must still handle a uniqueness error from the later -write. +Payload manually checks fields and compound indexes configured with `unique: true` during collection +validation. These checks can identify conflicts before a write, but the database remains +authoritative for uniqueness constraints. ## REST API From 2314aa4c7c49a7bbc67f6f4250208fd462d18a91 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Fri, 2 Oct 2026 16:30:46 +0100 Subject: [PATCH 07/12] chore: reduce validation documentation detail Written with AI --- docs/validation/overview.mdx | 52 +++++------------------------------- 1 file changed, 7 insertions(+), 45 deletions(-) diff --git a/docs/validation/overview.mdx b/docs/validation/overview.mdx index 07f0ff38741..4aef2f873e0 100644 --- a/docs/validation/overview.mdx +++ b/docs/validation/overview.mdx @@ -174,19 +174,7 @@ The collection create endpoint requires an object body. The collection by-ID and allow the body to be omitted. Those endpoints use the newest available draft as their base, falling back to the main document when no draft exists. -The body is always candidate document data. Authentication, access context, and other operation -controls come from the HTTP request, not body properties. - -| Outcome | Status | -| ------------------------------------------ | ------ | -| Valid or invalid field data | `200` | -| Missing, unknown, or unavailable locale | `400` | -| Missing or malformed required request data | `400` | -| Validation access denied | `403` | -| Collection document not found | `404` | - -Other errors use Payload's normal REST error handling. REST validation always enforces access -control. +REST validation always enforces access control. ## GraphQL API @@ -219,15 +207,8 @@ uses the locales returned by `localization.filterAvailableLocales`, when configu localized validation passes are tagged with the locale that failed; non-localized validation may omit `locale`. -A public multi-locale Local or REST call has one `data` payload. Payload independently merges that -same candidate into every selected locale, then aggregates the results. If a custom interface has -unsaved localized values for only its active locale, do not send those values as a flat -multi-locale candidate. - -Publish-all operations do not automatically run on-demand validation for every locale. When an -application needs this check before publishing, call the Local or REST validation API with -`locale: 'all'` first. Automatic validation of every locale during publish-all will be added in a -follow-up change. +When validating multiple locales, Payload validates the same `data` against each selected locale +and combines the results. Fallback locale values are disabled during on-demand validation. Missing data must pass validation in the locale being checked. @@ -272,23 +253,11 @@ Collection, global, and field access functions receive `req.operation === 'valid validators and `beforeValidate` and `beforeChange` hooks receive `operation: 'validate'`. Payload does not report the operation as `create` or `update`. -When `validate` access returns a `where` constraint, validation throws `Forbidden` if the stored -document does not match it. Collection create validation requires boolean access because there is no -stored document to test against the constraint. - -A collection create-candidate validation has no stored document against which Payload can evaluate -a `where` constraint. Its `validate` access must therefore return a boolean. Payload throws -`Forbidden` if it returns a `where` constraint for a validation call without an `id`. +`where` access constraints apply when validating stored documents. Collection create validation +requires boolean access because there is no stored document to evaluate against a constraint. -For draft-enabled globals, Payload identifies the newest draft before it applies a `where` access -constraint. If that exact draft does not satisfy the constraint, validation throws `Forbidden` -instead of selecting an older draft that does satisfy it. Payload uses the main global only when no -draft is available. - -The validation lifecycle itself does not persist the candidate. Payload also rejects document, -global, upload, and version writes that reuse the active validation request. Hooks still run, -however, and can cause side effects through other Payload APIs, an external service, a raw database -client, or a separate Payload request. Branch side-effecting hooks when needed: +Validation does not persist the candidate, but hooks can still cause side effects through Payload +APIs or external services. Check the operation before running side-effecting hooks: ```ts import type { CollectionBeforeChangeHook } from 'payload' @@ -305,10 +274,3 @@ const beforeChange: CollectionBeforeChangeHook = async ({ return data } ``` - -## Current scope - -On-demand validation is available through the Local, REST, and GraphQL APIs. GraphQL only -validates one locale per call; multi-locale and `'all'` validation are Local and REST API only. It -does not add a standalone "validate all locales" Admin UI action, bulk Admin validation, or -fallback-locale validation. From d7a550d584a437f558eae7c9838b8f3075ddc6a7 Mon Sep 17 00:00:00 2001 From: Paul Date: Fri, 2 Oct 2026 16:39:33 +0100 Subject: [PATCH 08/12] Update overview.mdx --- docs/validation/overview.mdx | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/docs/validation/overview.mdx b/docs/validation/overview.mdx index 4aef2f873e0..b3c00ff749e 100644 --- a/docs/validation/overview.mdx +++ b/docs/validation/overview.mdx @@ -9,20 +9,6 @@ keywords: validation, local api, rest api, localization, publishing, access cont You can validate a collection document or global without writing it to the database. Call `payload.validate()` for a collection or `payload.validateGlobal()` for a global. -Field validation failures are returned in the result: - -```ts -import type { ValidationFieldError } from 'payload' - -type ValidationResult = { - valid: boolean - errors: ValidationFieldError[] -} -``` - -Every error has a `path` and `message`. Localized errors also set `locale`. Access denials, missing -documents, invalid arguments, and other hook errors throw normally. - ## Local API Omit `id` and provide `data` to validate a collection create candidate. For stored documents and @@ -119,8 +105,7 @@ authoritative for uniqueness constraints. ## REST API Validation endpoints use `POST`. Send candidate data as a JSON object and select locales with the -required `locale` query parameter. These examples use the default `/api` base path; custom -`routes.api` configuration changes that prefix. +required `locale` query parameter. ```ts // Collection create candidate From d3986a1a35aae4b858289e45aedfa082cf9e9108 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Fri, 2 Oct 2026 16:55:52 +0100 Subject: [PATCH 09/12] chore: default validation locale Written with AI --- docs/validation/overview.mdx | 16 ++--- .../src/collections/endpoints/validate.ts | 17 +++-- .../collections/operations/local/validate.ts | 12 ++-- .../payload/src/globals/endpoints/validate.ts | 10 +-- .../src/globals/operations/local/validate.ts | 14 ++-- .../src/utilities/resolveValidationLocales.ts | 4 +- .../utilities/runLocaleScopedValidation.ts | 5 +- test/types/types.spec.ts | 11 ++- test/validate/int.spec.ts | 71 ++++++++++++------- 9 files changed, 93 insertions(+), 67 deletions(-) diff --git a/docs/validation/overview.mdx b/docs/validation/overview.mdx index b3c00ff749e..0fff4cb6c4a 100644 --- a/docs/validation/overview.mdx +++ b/docs/validation/overview.mdx @@ -59,8 +59,8 @@ const globalResult = await payload.validateGlobal({ }) ``` -For projects without localization, pass `locale: null` to the Local API or use `?locale=all` with -the REST API. +If you omit `locale`, Payload uses the request locale or the configured default locale. Projects +without localization do not need to provide one. Use `context` to add values to `req.context` for the validation lifecycle. You can also reuse a partial request with `req`. @@ -104,8 +104,8 @@ authoritative for uniqueness constraints. ## REST API -Validation endpoints use `POST`. Send candidate data as a JSON object and select locales with the -required `locale` query parameter. +Validation endpoints use `POST`. Send candidate data as a JSON object. The optional `locale` query +parameter selects locales; otherwise Payload uses the request locale or configured default locale. ```ts // Collection create candidate @@ -187,10 +187,10 @@ use the Local or REST API for that. GraphQL validation always enforces access co ## Locale semantics -`locale` accepts one locale, a non-empty array, or `'all'`. Duplicate locales are removed. `'all'` -uses the locales returned by `localization.filterAvailableLocales`, when configured. Errors from -localized validation passes are tagged with the locale that failed; non-localized validation may -omit `locale`. +`locale` accepts one locale, a non-empty array, or `'all'`, and defaults to the request locale or the +configured default locale. Duplicate locales are removed. `'all'` uses the locales returned by +`localization.filterAvailableLocales`, when configured. Errors from localized validation passes are +tagged with the locale that failed; non-localized validation may omit `locale`. When validating multiple locales, Payload validates the same `data` against each selected locale and combines the results. diff --git a/packages/payload/src/collections/endpoints/validate.ts b/packages/payload/src/collections/endpoints/validate.ts index 4a0d577750e..a3cb65e2eec 100644 --- a/packages/payload/src/collections/endpoints/validate.ts +++ b/packages/payload/src/collections/endpoints/validate.ts @@ -16,12 +16,14 @@ import { validateLocal } from '../operations/local/validate.js' /** * Validates collection create candidate data. * - * `POST {routes.api}/{collection}/validate` requires an object body and one or more `locale` query - * parameters, or `locale=all`. Field validation failures return a `200` ValidationResult. + * `POST {routes.api}/{collection}/validate` requires an object body. The optional `locale` query + * parameter accepts one or more locales, or `locale=all`. Field validation failures return a + * `200` ValidationResult. */ export const validateHandler: PayloadHandler = async (req) => { const collection = getRequestCollection(req) - const locale = parseValidationLocaleSelector(req.query.locale) + const locale = + req.query.locale === undefined ? undefined : parseValidationLocaleSelector(req.query.locale) assertValidationData(req.data) @@ -45,14 +47,15 @@ export const validateHandler: PayloadHandler = async (req) => { /** * Validates a stored collection document with optional partial candidate data. * - * `POST {routes.api}/{collection}/{id}/validate` accepts an optional object body and requires one - * or more `locale` query parameters, or `locale=all`. The newest available draft is used as the - * base, falling back to the main document. Field validation failures return a `200` + * `POST {routes.api}/{collection}/{id}/validate` accepts an optional object body. The optional + * `locale` query parameter accepts one or more locales, or `locale=all`. The newest available draft + * is used as the base, falling back to the main document. Field validation failures return a `200` * ValidationResult. */ export const validateByIDHandler: PayloadHandler = async (req) => { const { id, collection } = getRequestCollectionWithID(req) - const locale = parseValidationLocaleSelector(req.query.locale) + const locale = + req.query.locale === undefined ? undefined : parseValidationLocaleSelector(req.query.locale) if (req.data !== undefined) { assertValidationData(req.data) diff --git a/packages/payload/src/collections/operations/local/validate.ts b/packages/payload/src/collections/operations/local/validate.ts index a131d112331..88daf7d792f 100644 --- a/packages/payload/src/collections/operations/local/validate.ts +++ b/packages/payload/src/collections/operations/local/validate.ts @@ -47,13 +47,13 @@ type BaseOptions = { */ context?: RequestContext /** - * A locale, a non-empty locale array, or `'all'`. + * A locale, a non-empty locale array, or `'all'`. Defaults to the request locale, or the + * configured default locale. * * Each selected locale receives an independent copy of the same candidate `data`. - * `'all'` resolves through `localization.filterAvailableLocales` when configured. Use `null` - * for projects without localization. + * `'all'` resolves through `localization.filterAvailableLocales` when configured. */ - locale: ValidationLocaleSelector + locale?: ValidationLocaleSelector /** * An existing request to reuse for user, locale, and context. */ @@ -147,10 +147,6 @@ export async function validateLocalWithDataLocale( validationTrash, } = options - if (locale === undefined) { - throw new APIError('Validation requires a locale.', httpStatus.BAD_REQUEST) - } - if (id === undefined && data === undefined) { throw new APIError('Validation create simulation requires data.', httpStatus.BAD_REQUEST) } diff --git a/packages/payload/src/globals/endpoints/validate.ts b/packages/payload/src/globals/endpoints/validate.ts index f8119043ae6..fd4ffd86f8e 100644 --- a/packages/payload/src/globals/endpoints/validate.ts +++ b/packages/payload/src/globals/endpoints/validate.ts @@ -13,13 +13,15 @@ import { validateGlobalLocal } from '../operations/local/validate.js' /** * Validates a global with optional partial candidate data. * - * `POST {routes.api}/globals/{global}/validate` accepts an optional object body and requires one or - * more `locale` query parameters, or `locale=all`. The newest available draft is used as the base, - * falling back to the main global. Field validation failures return a `200` ValidationResult. + * `POST {routes.api}/globals/{global}/validate` accepts an optional object body. The optional + * `locale` query parameter accepts one or more locales, or `locale=all`. The newest available draft + * is used as the base, falling back to the main global. Field validation failures return a `200` + * ValidationResult. */ export const validateHandler: PayloadHandler = async (req) => { const globalConfig = getRequestGlobal(req) - const locale = parseValidationLocaleSelector(req.query.locale) + const locale = + req.query.locale === undefined ? undefined : parseValidationLocaleSelector(req.query.locale) if (req.data !== undefined) { assertValidationData(req.data) diff --git a/packages/payload/src/globals/operations/local/validate.ts b/packages/payload/src/globals/operations/local/validate.ts index 1eff18cf5ee..8248cf0533a 100644 --- a/packages/payload/src/globals/operations/local/validate.ts +++ b/packages/payload/src/globals/operations/local/validate.ts @@ -1,7 +1,5 @@ import type { DeepPartial } from 'ts-essentials' -import { status as httpStatus } from 'http-status' - import type { ValidationResult } from '../../../collections/operations/local/validate.js' import type { GlobalSlug, @@ -32,13 +30,13 @@ export type ValidateGlobalOptions = { /** Optional partial candidate data to merge over the selected stored global. */ data?: DeepPartial, 'id'>> /** - * A locale, a non-empty locale array, or `'all'`. + * A locale, a non-empty locale array, or `'all'`. Defaults to the request locale, or the + * configured default locale. * * Each selected locale receives an independent copy of the same candidate `data`. - * `'all'` resolves through `localization.filterAvailableLocales` when configured. Use `null` - * for projects without localization. + * `'all'` resolves through `localization.filterAvailableLocales` when configured. */ - locale: ValidationLocaleSelector + locale?: ValidationLocaleSelector /** An existing request to reuse for user, locale, and context. */ req?: Partial /** The global slug to validate against. */ @@ -87,10 +85,6 @@ export async function validateGlobalLocalWithDataLocale config.slug === slug) if (!globalConfig) { diff --git a/packages/payload/src/utilities/resolveValidationLocales.ts b/packages/payload/src/utilities/resolveValidationLocales.ts index 36fc392c0ca..b6539754310 100644 --- a/packages/payload/src/utilities/resolveValidationLocales.ts +++ b/packages/payload/src/utilities/resolveValidationLocales.ts @@ -10,8 +10,8 @@ import { APIError } from '../errors/index.js' * Locales accepted by collection and global on-demand validation. * * A non-empty array validates its unique locale codes in the order provided. `'all'` validates - * every locale available to the request. Projects without localization use `null` in the Local - * API or `locale=all` in the REST API. + * every locale available to the request. When omitted by the public APIs, validation uses the + * request locale, or the configured default locale. */ /* eslint-disable @typescript-eslint/no-redundant-type-constituents */ export type ValidationLocaleSelector = diff --git a/packages/payload/src/utilities/runLocaleScopedValidation.ts b/packages/payload/src/utilities/runLocaleScopedValidation.ts index f092df6015c..882c8fd0467 100644 --- a/packages/payload/src/utilities/runLocaleScopedValidation.ts +++ b/packages/payload/src/utilities/runLocaleScopedValidation.ts @@ -36,7 +36,7 @@ export async function runLocaleScopedValidation({ context: RequestContext | undefined data: TData fields: Field[] - locale: ValidationLocaleSelector + locale: undefined | ValidationLocaleSelector payload: Payload req: Partial | undefined runPass: (args: { @@ -55,8 +55,9 @@ export async function runLocaleScopedValidation({ user: cloneValidationValue(user), }) baseReq.operation = 'validate' + const localeSelector = locale === undefined ? (baseReq.locale ?? null) : locale const locales = await resolveValidationLocales({ - locale, + locale: localeSelector, req: baseReq, }) const results = await runValidationLocalePasses({ diff --git a/test/types/types.spec.ts b/test/types/types.spec.ts index a0d9d58cf3b..1c89107b010 100644 --- a/test/types/types.spec.ts +++ b/test/types/types.spec.ts @@ -181,13 +181,13 @@ describe('Types testing', () => { expect().type.toHaveProperty('validate') }) - test('should require collection create data and a locale', () => { + test('should require collection create data and allow the locale to be omitted', () => { expect(payload.validate).type.toBeCallableWith({ collection: 'pages', data: {}, locale: null, }) - expect(payload.validate).type.not.toBeCallableWith({ + expect(payload.validate).type.toBeCallableWith({ collection: 'pages', data: {}, }) @@ -204,11 +204,18 @@ describe('Types testing', () => { }) test('should allow collection update and global validation data to be omitted', () => { + expect(payload.validate).type.toBeCallableWith({ + id: 'document-id', + collection: 'pages', + }) expect(payload.validate).type.toBeCallableWith({ id: 'document-id', collection: 'pages', locale: null, }) + expect(payload.validateGlobal).type.toBeCallableWith({ + slug: 'menu', + }) expect(payload.validateGlobal).type.toBeCallableWith({ slug: 'menu', locale: null, diff --git a/test/validate/int.spec.ts b/test/validate/int.spec.ts index 76d9bbea9f2..b6bc41916f3 100644 --- a/test/validate/int.spec.ts +++ b/test/validate/int.spec.ts @@ -872,6 +872,26 @@ test.suite('validate Local API', { config: './config.ts' }, () => { expect(localePassEvents.map(({ localeAtStart }) => localeAtStart)).toEqual(['es', 'en']) }) + test('should use the default locale when locale is omitted', async ({ payload }) => { + const result = await payload.validate({ + collection: validationCollectionSlug, + data: { + summary: 'candidate summary', + title: '', + }, + }) + + expect(result).toMatchObject({ + errors: [ + { + locale: 'en', + path: 'title', + }, + ], + valid: false, + }) + }) + test('should reject empty, unknown, and unavailable locale selectors', async ({ payload }) => { await expect( payload.validate({ @@ -1230,16 +1250,6 @@ test.suite('validate Local API', { config: './config.ts' }, () => { }, errorMessage: 'Validation create simulation requires data', }, - { - args: { - collection: validationCollectionSlug, - data: { - summary: 'candidate summary', - title: 'Candidate title', - }, - }, - errorMessage: 'Validation requires a locale', - }, ] for (const { args, errorMessage } of invalidArguments) { @@ -2060,6 +2070,27 @@ test.suite('validate Local API', { config: './config.ts' }, () => { expect(req.operation).toBe('read') }) + test('should use the default locale for global validation when locale is omitted', async ({ + payload, + }) => { + const result = await payload.validateGlobal({ + slug: validationGlobalSlug, + data: { + title: '', + }, + }) + + expect(result).toMatchObject({ + errors: [ + { + locale: 'en', + path: 'title', + }, + ], + valid: false, + }) + }) + test('should return errors for invalid partial global data without persisting it', async ({ payload, }) => { @@ -2390,7 +2421,7 @@ test.suite('validate Local API', { config: './config.ts' }, () => { payload, restClient, }) => { - const response = await restClient.POST(`/${validationCollectionSlug}/validate?locale=en`, { + const response = await restClient.POST(`/${validationCollectionSlug}/validate`, { body: JSON.stringify({ summary: 'candidate summary', title: '', @@ -2421,11 +2452,6 @@ test.suite('validate Local API', { config: './config.ts' }, () => { endpoint: `/${string}` expectedMessage: string }> = [ - { - body: { summary: 'candidate summary', title: 'Candidate title' }, - endpoint: `/${validationCollectionSlug}/validate`, - expectedMessage: 'Validation requires a locale.', - }, { body: { summary: 'candidate summary', title: 'Candidate title' }, endpoint: `/${validationCollectionSlug}/validate?locale=`, @@ -2615,14 +2641,11 @@ test.suite('validate Local API', { config: './config.ts' }, () => { }) test('should validate global data without persisting it', async ({ payload, restClient }) => { - const response = await restClient.POST( - `/globals/${validationGlobalSlug}/validate?locale=en`, - { - body: JSON.stringify({ - title: '', - }), - }, - ) + const response = await restClient.POST(`/globals/${validationGlobalSlug}/validate`, { + body: JSON.stringify({ + title: '', + }), + }) const afterValidation = await payload.findGlobal({ slug: validationGlobalSlug, locale: 'en', From a29f082e8e6ce57f4a032cf64bd3fad3322aa3d4 Mon Sep 17 00:00:00 2001 From: Paul Date: Fri, 2 Oct 2026 17:03:21 +0100 Subject: [PATCH 10/12] Update overview.mdx --- docs/validation/overview.mdx | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/docs/validation/overview.mdx b/docs/validation/overview.mdx index 0fff4cb6c4a..6f48f962774 100644 --- a/docs/validation/overview.mdx +++ b/docs/validation/overview.mdx @@ -59,12 +59,6 @@ const globalResult = await payload.validateGlobal({ }) ``` -If you omit `locale`, Payload uses the request locale or the configured default locale. Projects -without localization do not need to provide one. - -Use `context` to add values to `req.context` for the validation lifecycle. You can also reuse a -partial request with `req`. - ### Workflow readiness Validation failures do not throw a `ValidationError`. Check `valid` and use the locale, path, and @@ -104,8 +98,8 @@ authoritative for uniqueness constraints. ## REST API -Validation endpoints use `POST`. Send candidate data as a JSON object. The optional `locale` query -parameter selects locales; otherwise Payload uses the request locale or configured default locale. +Validation endpoints use `POST`. Send candidate data as a JSON object. Provide a `locale` query +parameter otherwise Payload uses the request locale or configured default locale. ```ts // Collection create candidate @@ -187,11 +181,6 @@ use the Local or REST API for that. GraphQL validation always enforces access co ## Locale semantics -`locale` accepts one locale, a non-empty array, or `'all'`, and defaults to the request locale or the -configured default locale. Duplicate locales are removed. `'all'` uses the locales returned by -`localization.filterAvailableLocales`, when configured. Errors from localized validation passes are -tagged with the locale that failed; non-localized validation may omit `locale`. - When validating multiple locales, Payload validates the same `data` against each selected locale and combines the results. @@ -201,7 +190,7 @@ in the locale being checked. ## Access control and hooks Validation falls back to the corresponding `update` access function for collections, globals, and -fields. The fallback still receives `req.operation === 'validate'`. Configure `validate` only when +fields. The fallback still receives `req.operation === 'validate'`. Configure `validate` when validation should use a different policy: ```ts @@ -242,7 +231,7 @@ does not report the operation as `create` or `update`. requires boolean access because there is no stored document to evaluate against a constraint. Validation does not persist the candidate, but hooks can still cause side effects through Payload -APIs or external services. Check the operation before running side-effecting hooks: +APIs or external services. You must check the operation before running side-effecting hooks: ```ts import type { CollectionBeforeChangeHook } from 'payload' From ed83aff2818f497ecaf1ab1c250dcbecb8a230a2 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Fri, 2 Oct 2026 18:13:36 +0100 Subject: [PATCH 11/12] chore: refine validation internals Written with AI --- .../collections/operations/local/validate.ts | 66 +---- .../src/collections/operations/validate.ts | 136 ++-------- .../payload/src/errors/ValidationError.ts | 5 +- .../src/fields/hooks/beforeChange/promise.ts | 2 - .../src/globals/operations/local/validate.ts | 38 +-- .../payload/src/globals/operations/update.ts | 3 +- .../src/globals/operations/validate.ts | 97 +------ packages/payload/src/index.ts | 8 +- packages/payload/src/types/index.ts | 4 +- packages/payload/src/types/validation.ts | 18 ++ .../utilities/cloneValidationRequest.spec.ts | 43 +++ .../src/utilities/cloneValidationRequest.ts | 217 +++++++++++++++ .../src/utilities/flattenDataByLocale.ts | 247 ------------------ .../utilities/getAccessOperationRequest.ts | 4 +- .../isValidationErrorPathLocalized.ts | 4 +- .../src/utilities/projectNonLocalizedData.ts | 153 ----------- .../src/utilities/resolveValidationLocales.ts | 160 ------------ .../utilities/runLocaleScopedValidation.ts | 72 +++-- .../src/utilities/runValidationLifecycle.ts | 160 ++++++++++++ .../src/utilities/toValidationResult.ts | 2 +- test/types/types.spec.ts | 5 + test/validate/int.spec.ts | 147 ++++++----- 22 files changed, 606 insertions(+), 985 deletions(-) create mode 100644 packages/payload/src/types/validation.ts create mode 100644 packages/payload/src/utilities/cloneValidationRequest.spec.ts create mode 100644 packages/payload/src/utilities/cloneValidationRequest.ts delete mode 100644 packages/payload/src/utilities/flattenDataByLocale.ts delete mode 100644 packages/payload/src/utilities/projectNonLocalizedData.ts create mode 100644 packages/payload/src/utilities/runValidationLifecycle.ts diff --git a/packages/payload/src/collections/operations/local/validate.ts b/packages/payload/src/collections/operations/local/validate.ts index 88daf7d792f..2bd6f751f26 100644 --- a/packages/payload/src/collections/operations/local/validate.ts +++ b/packages/payload/src/collections/operations/local/validate.ts @@ -8,9 +8,9 @@ import type { RequestContext, SharedLocalAPIOptions, User, - ValidationFieldError, } from '../../../index.js' import type { PayloadRequest } from '../../../types/index.js' +import type { ValidationResult } from '../../../types/validation.js' import type { ValidationLocaleSelector } from '../../../utilities/resolveValidationLocales.js' import type { DataFromCollectionSlug, @@ -22,23 +22,6 @@ import { APIError } from '../../../errors/index.js' import { runLocaleScopedValidation } from '../../../utilities/runLocaleScopedValidation.js' import { validateOperation } from '../validate.js' -/** - * The result of validating a collection or global document candidate without persisting it. - * - * Field validation failures are returned in this result. Access denials, invalid arguments, - * missing documents, and other lifecycle errors throw instead. - */ -export type ValidationResult = { - /** - * Field validation errors. Errors from localized passes are tagged with the locale that failed; - * non-localized validation may omit the locale. - * Empty when {@link valid} is `true`. - */ - errors: ValidationFieldError[] - /** Whether the candidate passed field validation in every selected locale. */ - valid: boolean -} - type BaseOptions = { /** The collection slug to validate against. */ collection: TSlug @@ -90,61 +73,17 @@ export type ValidateCollectionOptions = id: DataFromCollectionSlug['id'] } & BaseOptions) -type InternalValidateCollectionOptions = { - /** - * Whether `data` stores each localized field as a locale-code-keyed object, as the internal - * publish-all-locales candidate does, rather than a flat, single-locale candidate. - */ - dataIsLocaleKeyed?: boolean - validationDataLocale?: string - validationTrash?: boolean -} & ValidateCollectionOptions - export async function validateLocal( payload: Payload, options: ValidateCollectionOptions, -): Promise { - const publicOptions = { - collection: options.collection, - context: options.context, - draft: options.draft, - locale: options.locale, - overrideAccess: options.overrideAccess, - req: options.req, - user: options.user, - } - - // Both branches call the same function with the same data; the split exists only because - // `InternalValidateCollectionOptions`'s `id` follows the same discriminated union as the public - // `ValidateCollectionOptions`, so `id` must be omitted entirely rather than passed as `undefined`. - if (options.id === undefined) { - return validateLocalWithDataLocale(payload, { - ...publicOptions, - data: options.data, - }) - } - - return validateLocalWithDataLocale(payload, { - ...publicOptions, - id: options.id, - data: options.data, - }) -} - -export async function validateLocalWithDataLocale( - payload: Payload, - options: InternalValidateCollectionOptions, ): Promise { const { id, collection: collectionSlug, data, - dataIsLocaleKeyed, draft = false, locale, overrideAccess = false, - validationDataLocale, - validationTrash, } = options if (id === undefined && data === undefined) { @@ -171,14 +110,11 @@ export async function validateLocalWithDataLocale( id, collection, data: validationData, - dataIsLocaleKeyed, draft, onValidationData, overrideAccess, req, - trash: validationTrash, }), user: options.user, - validationDataLocale, }) } diff --git a/packages/payload/src/collections/operations/validate.ts b/packages/payload/src/collections/operations/validate.ts index 7cf6ce96ec6..303a5a80267 100644 --- a/packages/payload/src/collections/operations/validate.ts +++ b/packages/payload/src/collections/operations/validate.ts @@ -3,8 +3,8 @@ import type { DeepPartial } from 'ts-essentials' import type { FindOneArgs } from '../../database/types.js' import type { CollectionSlug, JsonObject } from '../../index.js' import type { PayloadRequest } from '../../types/index.js' +import type { ValidationResult } from '../../types/validation.js' import type { Collection, RequiredDataFromCollectionSlug, TypeWithID } from '../config/types.js' -import type { ValidationResult } from './local/validate.js' import { ensureUsernameOrEmail } from '../../auth/ensureUsernameOrEmail.js' import { executeAccess } from '../../auth/executeAccess.js' @@ -12,31 +12,20 @@ import { hasWhereAccessResult } from '../../auth/types.js' import { combineQueries } from '../../database/combineQueries.js' import { Forbidden, NotFound } from '../../errors/index.js' import { afterRead } from '../../fields/hooks/afterRead/index.js' -import { beforeChange } from '../../fields/hooks/beforeChange/index.js' -import { beforeValidate } from '../../fields/hooks/beforeValidate/index.js' import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' import { deepCopyObjectSimple } from '../../utilities/deepCopyObject.js' -import { deepMergeWithSourceArraysIgnoringUndefined } from '../../utilities/deepMerge.js' -import { flattenDataByLocale } from '../../utilities/flattenDataByLocale.js' -import { toValidationResult } from '../../utilities/toValidationResult.js' +import { runValidationLifecycle } from '../../utilities/runValidationLifecycle.js' import { appendVersionToQueryKey } from '../../versions/drafts/appendVersionToQueryKey.js' import { validateUniqueConstraints } from './utilities/validateUniqueConstraints.js' export type Arguments = { collection: Collection data?: DeepPartial> - /** - * Whether `data` stores each localized field as a locale-code-keyed object, as the internal - * publish-all-locales candidate does, rather than a flat, single-locale candidate. - * @default false - */ - dataIsLocaleKeyed?: boolean draft: boolean id?: number | string onValidationData?: (data: JsonObject) => void overrideAccess: boolean req: PayloadRequest - trash?: boolean } export async function validateOperation( @@ -56,12 +45,10 @@ async function validateOperationWithScopedRequest( id, collection, data: incomingData, - dataIsLocaleKeyed = false, draft, onValidationData, overrideAccess, req, - trash, }: Arguments): Promise { const collectionConfig = collection.config @@ -82,7 +69,7 @@ async function validateOperationWithScopedRequest( if (id !== undefined) { const idWhere = appendNonTrashedFilter({ enableTrash: collectionConfig.trash, - trash: Boolean(trash), + trash: false, where: { id: { equals: id } }, }) const where = combineQueries(idWhere, accessResult) @@ -161,18 +148,13 @@ async function validateOperationWithScopedRequest( showHiddenFields: true, }) - let data = flattenDataByLocale({ - configBlockReferences: req.payload.config.blocks, - dataIsLocaleKeyed, - docWithLocales: deepCopyObjectSimple(incomingData ?? {}) as JsonObject, - fields: collectionConfig.fields, - locale: req.locale!, - }) - - try { - onValidationData?.(deepMergeWithSourceArraysIgnoringUndefined(originalDoc, data)) + return runValidationLifecycle({ + id, + beforeValidation: ({ data }) => { + if (!collectionConfig.auth) { + return + } - if (collectionConfig.auth) { if (id === undefined) { ensureUsernameOrEmail({ authOptions: collectionConfig.auth, @@ -191,87 +173,21 @@ async function validateOperationWithScopedRequest( req, }) } - } - - data = await beforeValidate({ - id, - collection: collectionConfig, - context: req.context, - data, - doc: originalDoc, - global: null, - operation: 'validate', - overrideAccess, - req, - }) - onValidationData?.(data) - - if (collectionConfig.hooks.beforeValidate?.length) { - for (const hook of collectionConfig.hooks.beforeValidate) { - data = - (await hook({ - collection: collectionConfig, - context: req.context, - data, - operation: 'validate', - originalDoc, - req, - })) || data - } - } - - if (collectionConfig.hooks.beforeChange?.length) { - for (const hook of collectionConfig.hooks.beforeChange) { - data = - (await hook({ - collection: collectionConfig, - context: req.context, - data, - operation: 'validate', - originalDoc, - req, - })) || data - } - } - - onValidationData?.(data) - - let processedData = data - - await beforeChange({ - id, - collection: collectionConfig, - context: req.context, - data: id === undefined ? data : { ...data, id }, - doc: originalDoc, - docWithLocales, - global: null, - onDataProcessed: (result) => { - processedData = result - }, - operation: 'validate', - overrideAccess, - req, - }) - - const validationData = deepMergeWithSourceArraysIgnoringUndefined( - originalDoc, - processedData, - ) - onValidationData?.(validationData) - - await validateUniqueConstraints({ - id, - collection: collectionConfig, - data: validationData, - req, - }) - } catch (error) { - return toValidationResult({ error, req }) - } - - return { - errors: [], - valid: true, - } + }, + collection: collectionConfig, + docWithLocales, + global: null, + incomingData: incomingData as JsonObject | undefined, + onValidationData, + originalDoc, + overrideAccess, + req, + validateData: ({ data }) => + validateUniqueConstraints({ + id, + collection: collectionConfig, + data, + req, + }), + }) } diff --git a/packages/payload/src/errors/ValidationError.ts b/packages/payload/src/errors/ValidationError.ts index 4287c4d7402..7b7edbf1519 100644 --- a/packages/payload/src/errors/ValidationError.ts +++ b/packages/payload/src/errors/ValidationError.ts @@ -52,7 +52,10 @@ export class ValidationError extends APIError<{ // delete to avoid logging the whole req delete results['req'] - const spansMultipleLocales = new Set(results.errors.map((f) => f.locale)).size > 1 + const locales = results.errors + .map((fieldError) => fieldError.locale) + .filter((locale): locale is string => Boolean(locale)) + const spansMultipleLocales = new Set(locales).size > 1 super( `${message} ${results.errors diff --git a/packages/payload/src/fields/hooks/beforeChange/promise.ts b/packages/payload/src/fields/hooks/beforeChange/promise.ts index 9380dc42405..d4e2ec13cff 100644 --- a/packages/payload/src/fields/hooks/beforeChange/promise.ts +++ b/packages/payload/src/fields/hooks/beforeChange/promise.ts @@ -262,7 +262,6 @@ export const promise = async ({ errors.push({ label: blockLabelPath, - locale: req.locale ?? undefined, message: req.t('validation:invalidBlock', { block: block.blockType }), path: `${path}.${rowIndex}.id`, }) @@ -281,7 +280,6 @@ export const promise = async ({ errors.push({ label: fieldLabel, - locale: req.locale ?? undefined, message: validationResult, path, }) diff --git a/packages/payload/src/globals/operations/local/validate.ts b/packages/payload/src/globals/operations/local/validate.ts index 8248cf0533a..07c3b846a6e 100644 --- a/packages/payload/src/globals/operations/local/validate.ts +++ b/packages/payload/src/globals/operations/local/validate.ts @@ -1,6 +1,5 @@ import type { DeepPartial } from 'ts-essentials' -import type { ValidationResult } from '../../../collections/operations/local/validate.js' import type { GlobalSlug, Payload, @@ -9,6 +8,7 @@ import type { User, } from '../../../index.js' import type { PayloadRequest } from '../../../types/index.js' +import type { ValidationResult } from '../../../types/validation.js' import type { ValidationLocaleSelector } from '../../../utilities/resolveValidationLocales.js' import type { DataFromGlobalSlug, DraftFlagFromGlobalSlug } from '../../config/types.js' @@ -46,43 +46,11 @@ export type ValidateGlobalOptions = { } & DraftFlagFromGlobalSlug & Pick -type InternalValidateGlobalOptions = { - /** - * Whether `data` stores each localized field as a locale-code-keyed object, as the internal - * publish-all-locales candidate does, rather than a flat, single-locale candidate. - */ - dataIsLocaleKeyed?: boolean - validationDataLocale?: string -} & ValidateGlobalOptions - export async function validateGlobalLocal( payload: Payload, options: ValidateGlobalOptions, ): Promise { - return validateGlobalLocalWithDataLocale(payload, { - slug: options.slug, - context: options.context, - data: options.data, - draft: options.draft, - locale: options.locale, - overrideAccess: options.overrideAccess, - req: options.req, - user: options.user, - }) -} - -export async function validateGlobalLocalWithDataLocale( - payload: Payload, - options: InternalValidateGlobalOptions, -): Promise { - const { - slug, - data, - dataIsLocaleKeyed, - locale, - overrideAccess = false, - validationDataLocale, - } = options + const { slug, data, locale, overrideAccess = false } = options const { draft = false } = options const globalConfig = payload.globals.config.find((config) => config.slug === slug) @@ -102,7 +70,6 @@ export async function validateGlobalLocalWithDataLocale = { data?: DeepPartial, 'id'>> - /** - * Whether `data` stores each localized field as a locale-code-keyed object, as the internal - * publish-all-locales candidate does, rather than a flat, single-locale candidate. - * @default false - */ - dataIsLocaleKeyed?: boolean draft: boolean globalConfig: SanitizedGlobalConfig onValidationData?: (data: JsonObject) => void @@ -54,7 +44,6 @@ export async function validateOperation( async function validateOperationWithScopedRequest({ slug, data: incomingData, - dataIsLocaleKeyed = false, draft, globalConfig, onValidationData, @@ -93,80 +82,16 @@ async function validateOperationWithScopedRequest({ showHiddenFields: true, }) - let data = flattenDataByLocale({ - configBlockReferences: req.payload.config.blocks, - dataIsLocaleKeyed, - docWithLocales: deepCopyObjectSimple(incomingData ?? {}) as JsonObject, - fields: globalConfig.fields, - locale: req.locale!, + return runValidationLifecycle({ + collection: null, + docWithLocales, + global: globalConfig, + incomingData: incomingData as JsonObject | undefined, + onValidationData, + originalDoc, + overrideAccess, + req, }) - - try { - onValidationData?.(deepMergeWithSourceArraysIgnoringUndefined(originalDoc, data)) - - data = await beforeValidate({ - collection: null, - context: req.context, - data, - doc: originalDoc, - global: globalConfig, - operation: 'validate', - overrideAccess, - req, - }) - onValidationData?.(data) - - if (globalConfig.hooks.beforeValidate?.length) { - for (const hook of globalConfig.hooks.beforeValidate) { - data = - (await hook({ - context: req.context, - data, - global: globalConfig, - operation: 'validate', - originalDoc, - overrideAccess, - req, - })) || data - } - } - - if (globalConfig.hooks.beforeChange?.length) { - for (const hook of globalConfig.hooks.beforeChange) { - data = - (await hook({ - context: req.context, - data, - global: globalConfig, - operation: 'validate', - originalDoc, - overrideAccess, - req, - })) || data - } - } - - onValidationData?.(data) - - await beforeChange({ - collection: null, - context: req.context, - data, - doc: originalDoc, - docWithLocales, - global: globalConfig, - operation: 'validate', - overrideAccess, - req, - }) - } catch (error) { - return toValidationResult({ error, req }) - } - - return { - errors: [], - valid: true, - } } /** diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index 3ef5d3f1510..a8ba24269a5 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -53,6 +53,7 @@ import type { TransformCollectionWithSelect, TransformGlobalWithSelect, } from './types/index.js' +import type { ValidationResult } from './types/validation.js' import type { TraverseFieldsCallback } from './utilities/traverseFields.js' import { countLocal, type CountOptions } from './collections/operations/local/count.js' @@ -101,7 +102,6 @@ import { import { type ValidateCollectionOptions, validateLocal, - type ValidationResult, } from './collections/operations/local/validate.js' import { countGlobalVersionsLocal, @@ -1586,10 +1586,7 @@ export { updateDocumentInputSchema, updateDocumentLocalInputSchema, } from './collections/operations/inputSchemas.js' -export type { - ValidateCollectionOptions, - ValidationResult, -} from './collections/operations/local/validate.js' +export type { ValidateCollectionOptions } from './collections/operations/local/validate.js' export { restoreVersionOperation } from './collections/operations/restoreVersion.js' export { updateOperation } from './collections/operations/update.js' export { updateByIDOperation } from './collections/operations/updateByID.js' @@ -2052,6 +2049,7 @@ export { } from './queues/utilities/getCurrentDate.js' export { getLocalI18n } from './translations/getLocalI18n.js' export * from './types/index.js' +export type { ValidationResult } from './types/validation.js' export { getFileByPath } from './uploads/getFileByPath.js' export { _internal_safeFetchGlobal } from './uploads/safeFetch.js' export type * from './uploads/types.js' diff --git a/packages/payload/src/types/index.ts b/packages/payload/src/types/index.ts index 31a01cd5985..43df18f90f8 100644 --- a/packages/payload/src/types/index.ts +++ b/packages/payload/src/types/index.ts @@ -131,7 +131,7 @@ export interface PayloadRequest PayloadRequestData { headers: Request['headers'] /** The active Payload operation. */ - operation?: FieldOperation + operation?: Operation } export type { HasManyRelationshipOperator, Operator } @@ -199,7 +199,7 @@ export type Document = any * or `update`. */ export type Operation = 'create' | 'delete' | 'read' | 'update' | 'validate' -export type FieldOperation = Operation +export type FieldOperation = Exclude export type BeforeValidateOperation = Extract export type VersionOperations = 'readVersions' export type AuthOperations = 'unlock' diff --git a/packages/payload/src/types/validation.ts b/packages/payload/src/types/validation.ts new file mode 100644 index 00000000000..a6c47ba31b5 --- /dev/null +++ b/packages/payload/src/types/validation.ts @@ -0,0 +1,18 @@ +import type { ValidationFieldError } from '../errors/ValidationError.js' + +/** + * The result of validating a collection or global document candidate without persisting it. + * + * Field validation failures are returned in this result. Access denials, invalid arguments, + * missing documents, and other lifecycle errors throw instead. + */ +export type ValidationResult = { + /** + * Field validation errors. Errors from localized passes are tagged with the locale that failed; + * non-localized validation may omit the locale. + * Empty when {@link valid} is `true`. + */ + errors: ValidationFieldError[] + /** Whether the candidate passed field validation in every selected locale. */ + valid: boolean +} diff --git a/packages/payload/src/utilities/cloneValidationRequest.spec.ts b/packages/payload/src/utilities/cloneValidationRequest.spec.ts new file mode 100644 index 00000000000..d8670dbdb85 --- /dev/null +++ b/packages/payload/src/utilities/cloneValidationRequest.spec.ts @@ -0,0 +1,43 @@ +import type { PayloadRequest } from '../types/index.js' + +import { describe, expect, it } from 'vitest' + +import { cloneValidationRequest } from './cloneValidationRequest.js' + +describe('cloneValidationRequest', () => { + it('should isolate file metadata without copying file buffers', () => { + const fileBuffer = Buffer.from('validation upload') + const req = { + file: { + data: fileBuffer, + mimetype: 'text/plain', + name: 'original.txt', + size: fileBuffer.byteLength, + }, + files: { + attachment: [ + { + data: fileBuffer, + mimetype: 'text/plain', + name: 'attachment.txt', + size: fileBuffer.byteLength, + }, + ], + }, + } satisfies Partial + + const clonedReq = cloneValidationRequest({ request: req }) + + expect(clonedReq.file).not.toBe(req.file) + expect(clonedReq.file?.data).toBe(fileBuffer) + expect(clonedReq.files?.attachment).not.toBe(req.files.attachment) + expect(clonedReq.files?.attachment[0]).not.toBe(req.files.attachment[0]) + expect(clonedReq.files?.attachment[0]?.data).toBe(fileBuffer) + + clonedReq.file!.name = 'changed.txt' + clonedReq.files!.attachment[0]!.name = 'changed-attachment.txt' + + expect(req.file.name).toBe('original.txt') + expect(req.files.attachment[0]!.name).toBe('attachment.txt') + }) +}) diff --git a/packages/payload/src/utilities/cloneValidationRequest.ts b/packages/payload/src/utilities/cloneValidationRequest.ts new file mode 100644 index 00000000000..634d9028f1f --- /dev/null +++ b/packages/payload/src/utilities/cloneValidationRequest.ts @@ -0,0 +1,217 @@ +import type { RequestContext, User } from '../index.js' +import type { PayloadRequest } from '../types/index.js' + +const sharedValidationRequestProperties = new Set([ + 'i18n', + 'payload', + 'server', + 'signal', + 't', + 'transactionID', +]) + +export function cloneValidationContext({ + context, +}: { + context: RequestContext | undefined +}): RequestContext | undefined { + return cloneValidationValue(context) +} + +export function cloneValidationData({ data }: { data: TData }): TData { + return cloneValidationValue(data) +} + +export function cloneValidationRequest({ + request, +}: { + request: Partial | undefined +}): Partial { + if (!request) { + return {} + } + + const payloadRequest: Partial = request + const fetchRequest: Request | undefined = + typeof Request !== 'undefined' && request instanceof Request ? request : undefined + const canCloneFetchRequest = fetchRequest && !fetchRequest.bodyUsed + let clonedRequest: Record + + if (canCloneFetchRequest) { + clonedRequest = fetchRequest.clone() as unknown as Record + } else { + clonedRequest = {} + } + + for (const [key, value] of Object.entries(payloadRequest)) { + if (key === 'payloadDataLoader') { + continue + } + + if (key === 'file') { + clonedRequest.file = cloneValidationFile(payloadRequest.file) + continue + } + + if (key === 'files') { + clonedRequest.files = cloneValidationFiles(payloadRequest.files) + continue + } + + clonedRequest[key] = sharedValidationRequestProperties.has(key) + ? value + : cloneValidationValue(value) + } + + Object.assign(clonedRequest, { + context: cloneValidationValue(payloadRequest.context ?? {}), + query: cloneValidationValue(payloadRequest.query ?? {}), + routeParams: cloneValidationValue(payloadRequest.routeParams ?? {}), + }) + + if (!canCloneFetchRequest) { + Object.assign(clonedRequest, { + headers: cloneValidationValue(payloadRequest.headers), + method: payloadRequest.method, + signal: payloadRequest.signal, + url: payloadRequest.url, + }) + } + + return clonedRequest as Partial +} + +export function cloneValidationUser({ + user, +}: { + user: null | undefined | User +}): null | undefined | User { + return cloneValidationValue(user) +} + +function cloneValidationFile(file: PayloadRequest['file']): PayloadRequest['file'] { + if (!file) { + return file + } + + const { data, ...metadata } = file + + return { + ...cloneValidationValue(metadata), + data, + } +} + +function cloneValidationFiles(files: PayloadRequest['files']): PayloadRequest['files'] { + if (!files) { + return files + } + + return Object.fromEntries( + Object.entries(files).map(([fieldName, fileOrFiles]) => [ + fieldName, + Array.isArray(fileOrFiles) + ? fileOrFiles.map((file) => cloneValidationFile(file)!) + : cloneValidationFile(fileOrFiles)!, + ]), + ) +} + +function cloneValidationValue(value: T, cache = new WeakMap()): T { + if ((typeof value !== 'object' && typeof value !== 'function') || value === null) { + return value + } + + if (typeof value === 'function' || value instanceof Promise) { + return value + } + + const objectValue = value as object + const cachedValue = cache.get(objectValue) + + if (cachedValue) { + return cachedValue as T + } + + if (value instanceof Headers) { + return new Headers(value) as T + } + + if (value instanceof URLSearchParams) { + return new URLSearchParams(value) as T + } + + if (value instanceof URL) { + return new URL(value) as T + } + + if (value instanceof Date) { + return new Date(value) as T + } + + if (value instanceof RegExp) { + return new RegExp(value.source, value.flags) as T + } + + if (value instanceof ArrayBuffer) { + return value.slice(0) as T + } + + if (ArrayBuffer.isView(value)) { + if (Buffer.isBuffer(value)) { + return Buffer.from(value) as T + } + + if (value instanceof DataView) { + return new DataView(value.buffer.slice(0), value.byteOffset, value.byteLength) as T + } + + return new (value.constructor as new (input: typeof value) => typeof value)(value) + } + + if (value instanceof Map) { + const clonedMap = new Map() + cache.set(objectValue, clonedMap) + for (const [key, mapValue] of value) { + clonedMap.set(cloneValidationValue(key, cache), cloneValidationValue(mapValue, cache)) + } + return clonedMap as T + } + + if (value instanceof Set) { + const clonedSet = new Set() + cache.set(objectValue, clonedSet) + for (const setValue of value) { + clonedSet.add(cloneValidationValue(setValue, cache)) + } + return clonedSet as T + } + + if (typeof Blob !== 'undefined' && value instanceof Blob) { + return value + } + + const prototype = Object.getPrototypeOf(value) + + if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) { + return value + } + + const clonedValue: Record | unknown[] = Array.isArray(value) + ? [] + : Object.create(prototype) + cache.set(objectValue, clonedValue) + + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key) + + if (descriptor?.enumerable) { + ;(clonedValue as Record)[key] = cloneValidationValue( + (value as Record)[key], + cache, + ) + } + } + + return clonedValue as T +} diff --git a/packages/payload/src/utilities/flattenDataByLocale.ts b/packages/payload/src/utilities/flattenDataByLocale.ts deleted file mode 100644 index 263238253c0..00000000000 --- a/packages/payload/src/utilities/flattenDataByLocale.ts +++ /dev/null @@ -1,247 +0,0 @@ -import type { Block, Field, FlattenedBlock } from '../fields/config/types.js' -import type { SanitizedConfig } from '../index.js' -import type { JsonObject } from '../types/index.js' - -import { fieldAffectsData, fieldShouldBeLocalized, tabHasName } from '../fields/config/types.js' -import { deepCopyObjectSimple } from './deepCopyObject.js' - -type Args = { - configBlockReferences: SanitizedConfig['blocks'] - /** - * Whether `docWithLocales` stores each localized field as a locale-code-keyed object (the - * stored document representation, and the internal publish-all-locales candidate). Pass - * `false` for a flat, single-locale candidate, such as the data passed to `payload.validate()`. - * @default true - */ - dataIsLocaleKeyed?: boolean - docWithLocales: JsonObject - fields: Field[] - locale: string - parentIsLocalized?: boolean -} - -/** - * Returns a copy of locale-keyed data flattened to one locale and converts field storage - * representations needed by validators, without running after-read hooks, access control, - * sanitization, or population. - */ -export function flattenDataByLocale({ - configBlockReferences, - dataIsLocaleKeyed = true, - docWithLocales, - fields, - locale, - parentIsLocalized = false, -}: Args): JsonObject { - const result = deepCopyObjectSimple(docWithLocales) - - flattenFields({ - configBlockReferences, - data: result, - dataIsLocaleKeyed, - fields, - locale, - parentIsLocalized, - }) - - return result -} - -type FlattenFieldsArgs = { - configBlockReferences: SanitizedConfig['blocks'] - data: JsonObject - dataIsLocaleKeyed: boolean - fields: Field[] - locale: string - parentIsLocalized: boolean -} - -function flattenFields({ - configBlockReferences, - data, - dataIsLocaleKeyed, - fields, - locale, - parentIsLocalized, -}: FlattenFieldsArgs): void { - for (const field of fields) { - if (fieldAffectsData(field)) { - const isLocalized = fieldShouldBeLocalized({ field, parentIsLocalized }) - - if (isLocalized) { - data[field.name] = getLocaleValue({ - dataIsLocaleKeyed, - locale, - value: data[field.name], - }) - } - - data[field.name] = transformStoredFieldValue({ - field, - value: data[field.name], - }) - - const fieldValue = data[field.name] - const nestedParentIsLocalized = parentIsLocalized || Boolean(field.localized) - - switch (field.type) { - case 'array': { - if (Array.isArray(fieldValue)) { - for (const row of fieldValue) { - if (row && typeof row === 'object') { - flattenFields({ - configBlockReferences, - data: row, - dataIsLocaleKeyed, - fields: field.fields, - locale, - parentIsLocalized: nestedParentIsLocalized, - }) - } - } - } - break - } - - case 'blocks': { - if (Array.isArray(fieldValue)) { - for (const row of fieldValue) { - if (!row || typeof row !== 'object') { - continue - } - - const blockOrSlug = field.blocks.find((block) => { - const blockSlug = typeof block === 'string' ? block : block.slug - return blockSlug === row.blockType - }) - const block: Block | FlattenedBlock | undefined = - typeof blockOrSlug === 'string' - ? configBlockReferences?.find(({ slug }) => slug === blockOrSlug) - : blockOrSlug - - if (block) { - flattenFields({ - configBlockReferences, - data: row, - dataIsLocaleKeyed, - fields: block.fields, - locale, - parentIsLocalized: nestedParentIsLocalized, - }) - } - } - } - break - } - - case 'group': { - if (fieldValue && typeof fieldValue === 'object' && !Array.isArray(fieldValue)) { - flattenFields({ - configBlockReferences, - data: fieldValue, - dataIsLocaleKeyed, - fields: field.fields, - locale, - parentIsLocalized: nestedParentIsLocalized, - }) - } - break - } - } - - continue - } - - switch (field.type) { - case 'collapsible': - case 'group': - case 'row': - flattenFields({ - configBlockReferences, - data, - dataIsLocaleKeyed, - fields: field.fields, - locale, - parentIsLocalized, - }) - break - - case 'tabs': - for (const tab of field.tabs) { - if (tabHasName(tab)) { - const isLocalized = fieldShouldBeLocalized({ field: tab, parentIsLocalized }) - - if (isLocalized) { - data[tab.name] = getLocaleValue({ - dataIsLocaleKeyed, - locale, - value: data[tab.name], - }) - } - - const tabData = data[tab.name] - - if (tabData && typeof tabData === 'object' && !Array.isArray(tabData)) { - flattenFields({ - configBlockReferences, - data: tabData, - dataIsLocaleKeyed, - fields: tab.fields, - locale, - parentIsLocalized: parentIsLocalized || Boolean(tab.localized), - }) - } - } else { - flattenFields({ - configBlockReferences, - data, - dataIsLocaleKeyed, - fields: tab.fields, - locale, - parentIsLocalized, - }) - } - } - break - } - } -} - -function transformStoredFieldValue({ field, value }: { field: Field; value: unknown }): unknown { - switch (field.type) { - case 'point': { - if (Array.isArray(value)) { - return value - } - - if (value && typeof value === 'object') { - const coordinates = (value as Record).coordinates - - if (Array.isArray(coordinates) && coordinates.length === 2) { - return coordinates - } - } - - return value - } - - default: - return value - } -} - -function getLocaleValue({ - dataIsLocaleKeyed, - locale, - value, -}: { - dataIsLocaleKeyed: boolean - locale: string - value: unknown -}): unknown { - if (dataIsLocaleKeyed && value && typeof value === 'object' && !Array.isArray(value)) { - return (value as Record)[locale] - } - - return value -} diff --git a/packages/payload/src/utilities/getAccessOperationRequest.ts b/packages/payload/src/utilities/getAccessOperationRequest.ts index 325d94a6fb2..a4a77c683a0 100644 --- a/packages/payload/src/utilities/getAccessOperationRequest.ts +++ b/packages/payload/src/utilities/getAccessOperationRequest.ts @@ -1,4 +1,4 @@ -import type { FieldOperation, PayloadRequest } from '../types/index.js' +import type { Operation, PayloadRequest } from '../types/index.js' import { isolateObjectProperty } from './isolateObjectProperty.js' @@ -10,7 +10,7 @@ export function getAccessOperationRequest({ operation, req, }: { - operation: FieldOperation + operation: Operation req: PayloadRequest }): PayloadRequest { const operationRequest = isolateObjectProperty(req, 'operation') diff --git a/packages/payload/src/utilities/isValidationErrorPathLocalized.ts b/packages/payload/src/utilities/isValidationErrorPathLocalized.ts index f65f32df3a8..bdfbf8bd228 100644 --- a/packages/payload/src/utilities/isValidationErrorPathLocalized.ts +++ b/packages/payload/src/utilities/isValidationErrorPathLocalized.ts @@ -12,8 +12,8 @@ type IsValidationErrorPathLocalizedArgs = { } /** - * Reports whether a `ValidationFieldError.path` refers to a localized field, by walking `fields` - * and `data` together the same way `projectNonLocalizedData` does. Used to tell apart a candidate + * Reports whether a `ValidationFieldError.path` refers to a localized field by walking `fields` + * and `data` together. Used to tell apart a candidate * error that's inherently per-locale from one for a shared, non-localized field that every locale * pass in `runLocaleScopedValidation` re-validates identically. A path that can't be resolved * (unknown field, malformed path) is conservatively treated as localized, since wrongly collapsing diff --git a/packages/payload/src/utilities/projectNonLocalizedData.ts b/packages/payload/src/utilities/projectNonLocalizedData.ts deleted file mode 100644 index bfa15ab256c..00000000000 --- a/packages/payload/src/utilities/projectNonLocalizedData.ts +++ /dev/null @@ -1,153 +0,0 @@ -import type { Block, Field, FlattenedBlock } from '../fields/config/types.js' -import type { SanitizedConfig } from '../index.js' -import type { JsonObject } from '../types/index.js' - -import { fieldAffectsData, fieldShouldBeLocalized, tabHasName } from '../fields/config/types.js' -import { deepCopyObjectSimple } from './deepCopyObject.js' - -type ProjectNonLocalizedDataArgs = { - configBlockReferences: SanitizedConfig['blocks'] - data: JsonObject - fields: Field[] -} - -export function projectNonLocalizedData({ - configBlockReferences, - data, - fields, -}: ProjectNonLocalizedDataArgs): JsonObject { - const projectedData = deepCopyObjectSimple(data) - - removeLocalizedData({ - configBlockReferences, - data: projectedData, - fields, - parentIsLocalized: false, - }) - - return projectedData -} - -type RemoveLocalizedDataArgs = { - parentIsLocalized: boolean -} & ProjectNonLocalizedDataArgs - -function removeLocalizedData({ - configBlockReferences, - data, - fields, - parentIsLocalized, -}: RemoveLocalizedDataArgs): void { - for (const field of fields) { - if (fieldAffectsData(field)) { - if (parentIsLocalized || fieldShouldBeLocalized({ field, parentIsLocalized })) { - delete data[field.name] - continue - } - - const fieldValue = data[field.name] - - switch (field.type) { - case 'array': { - if (Array.isArray(fieldValue)) { - for (const row of fieldValue) { - if (isObject(row)) { - removeLocalizedData({ - configBlockReferences, - data: row, - fields: field.fields, - parentIsLocalized: false, - }) - } - } - } - break - } - - case 'blocks': { - if (Array.isArray(fieldValue)) { - for (const row of fieldValue) { - if (!isObject(row)) { - continue - } - - const blockOrSlug = field.blocks.find((block) => { - const slug = typeof block === 'string' ? block : block.slug - return slug === row.blockType - }) - const block: Block | FlattenedBlock | undefined = - typeof blockOrSlug === 'string' - ? configBlockReferences?.find(({ slug }) => slug === blockOrSlug) - : blockOrSlug - - if (block) { - removeLocalizedData({ - configBlockReferences, - data: row, - fields: block.fields, - parentIsLocalized: false, - }) - } - } - } - break - } - - case 'group': { - if (isObject(fieldValue)) { - removeLocalizedData({ - configBlockReferences, - data: fieldValue, - fields: field.fields, - parentIsLocalized: false, - }) - } - break - } - } - } else { - switch (field.type) { - case 'collapsible': - case 'group': - case 'row': { - removeLocalizedData({ - configBlockReferences, - data, - fields: field.fields, - parentIsLocalized, - }) - break - } - - case 'tabs': { - for (const tab of field.tabs) { - if (tabHasName(tab)) { - if (parentIsLocalized || fieldShouldBeLocalized({ field: tab, parentIsLocalized })) { - delete data[tab.name] - } else if (isObject(data[tab.name])) { - removeLocalizedData({ - configBlockReferences, - data: data[tab.name], - fields: tab.fields, - parentIsLocalized: false, - }) - } - } else { - removeLocalizedData({ - configBlockReferences, - data, - fields: tab.fields, - parentIsLocalized, - }) - } - } - break - } - } - } - } -} - -function isObject(value: unknown): value is JsonObject { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} diff --git a/packages/payload/src/utilities/resolveValidationLocales.ts b/packages/payload/src/utilities/resolveValidationLocales.ts index b6539754310..49440b5441e 100644 --- a/packages/payload/src/utilities/resolveValidationLocales.ts +++ b/packages/payload/src/utilities/resolveValidationLocales.ts @@ -21,14 +21,6 @@ export type ValidationLocaleSelector = /* eslint-enable @typescript-eslint/no-redundant-type-constituents */ const validationLocaleConcurrency = 3 -const sharedValidationRequestProperties = new Set([ - 'i18n', - 'payload', - 'server', - 'signal', - 't', - 'transactionID', -]) export async function resolveValidationLocales({ locale, @@ -141,155 +133,3 @@ export async function runValidationLocalePasses({ return results } - -export function cloneValidationRequest( - request: Partial | undefined, -): Partial { - if (!request) { - return {} - } - - const payloadRequest: Partial = request - const fetchRequest: Request | undefined = - typeof Request !== 'undefined' && request instanceof Request ? request : undefined - const canCloneFetchRequest = fetchRequest && !fetchRequest.bodyUsed - let clonedRequest: Record - - if (canCloneFetchRequest) { - clonedRequest = fetchRequest.clone() as unknown as Record - } else { - clonedRequest = {} - } - - for (const [key, value] of Object.entries(payloadRequest)) { - if (key === 'payloadDataLoader') { - continue - } - - clonedRequest[key] = sharedValidationRequestProperties.has(key) - ? value - : cloneValidationValue(value) - } - - // `context`/`query`/`routeParams` default to an empty object even when the source request never - // set them, since downstream code reads their properties without checking for `undefined` first. - Object.assign(clonedRequest, { - context: cloneValidationValue(payloadRequest.context ?? {}), - query: cloneValidationValue(payloadRequest.query ?? {}), - routeParams: cloneValidationValue(payloadRequest.routeParams ?? {}), - }) - - if (!canCloneFetchRequest) { - // `headers`/`method`/`signal`/`url` can also come from a Request-like object's prototype, so - // copy them explicitly when there is no usable native Fetch Request clone to preserve them. - // REST request bodies have already been consumed, but cached own methods such as `json` were - // copied by the loop above and remain available to hooks. - Object.assign(clonedRequest, { - headers: cloneValidationValue(payloadRequest.headers), - method: payloadRequest.method, - signal: payloadRequest.signal, - url: payloadRequest.url, - }) - } - - return clonedRequest as Partial -} - -export function cloneValidationValue(value: T, cache = new WeakMap()): T { - if ((typeof value !== 'object' && typeof value !== 'function') || value === null) { - return value - } - - if (typeof value === 'function' || value instanceof Promise) { - return value - } - - const objectValue = value as object - const cachedValue = cache.get(objectValue) - - if (cachedValue) { - return cachedValue as T - } - - if (value instanceof Headers) { - return new Headers(value) as T - } - - if (value instanceof URLSearchParams) { - return new URLSearchParams(value) as T - } - - if (value instanceof URL) { - return new URL(value) as T - } - - if (value instanceof Date) { - return new Date(value) as T - } - - if (value instanceof RegExp) { - return new RegExp(value.source, value.flags) as T - } - - if (value instanceof ArrayBuffer) { - return value.slice(0) as T - } - - if (ArrayBuffer.isView(value)) { - if (Buffer.isBuffer(value)) { - return Buffer.from(value) as T - } - - if (value instanceof DataView) { - return new DataView(value.buffer.slice(0), value.byteOffset, value.byteLength) as T - } - - return new (value.constructor as new (input: typeof value) => typeof value)(value) - } - - if (value instanceof Map) { - const clonedMap = new Map() - cache.set(objectValue, clonedMap) - for (const [key, mapValue] of value) { - clonedMap.set(cloneValidationValue(key, cache), cloneValidationValue(mapValue, cache)) - } - return clonedMap as T - } - - if (value instanceof Set) { - const clonedSet = new Set() - cache.set(objectValue, clonedSet) - for (const setValue of value) { - clonedSet.add(cloneValidationValue(setValue, cache)) - } - return clonedSet as T - } - - if (typeof Blob !== 'undefined' && value instanceof Blob) { - return value - } - - const prototype = Object.getPrototypeOf(value) - - if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) { - return value - } - - const clonedValue: Record | unknown[] = Array.isArray(value) - ? [] - : Object.create(prototype) - cache.set(objectValue, clonedValue) - - for (const key of Reflect.ownKeys(value)) { - const descriptor = Object.getOwnPropertyDescriptor(value, key) - - if (descriptor?.enumerable) { - ;(clonedValue as Record)[key] = cloneValidationValue( - (value as Record)[key], - cache, - ) - } - } - - return clonedValue as T -} diff --git a/packages/payload/src/utilities/runLocaleScopedValidation.ts b/packages/payload/src/utilities/runLocaleScopedValidation.ts index 882c8fd0467..24449b91b22 100644 --- a/packages/payload/src/utilities/runLocaleScopedValidation.ts +++ b/packages/payload/src/utilities/runLocaleScopedValidation.ts @@ -1,21 +1,29 @@ -import type { ValidationResult } from '../collections/operations/local/validate.js' import type { ValidationFieldError } from '../errors/index.js' import type { Field } from '../fields/config/types.js' -import type { Payload, RequestContext, SanitizedConfig, User } from '../index.js' +import type { Payload, RequestContext, User } from '../index.js' import type { JsonObject, PayloadRequest } from '../types/index.js' +import type { ValidationResult } from '../types/validation.js' +import { + cloneValidationContext, + cloneValidationData, + cloneValidationRequest, + cloneValidationUser, +} from './cloneValidationRequest.js' import { createPayloadRequest } from './createPayloadRequest.js' import { isValidationErrorPathLocalized } from './isValidationErrorPathLocalized.js' -import { projectNonLocalizedData } from './projectNonLocalizedData.js' import { - cloneValidationRequest, - cloneValidationValue, resolveValidationConcurrency, resolveValidationLocales, runValidationLocalePasses, type ValidationLocaleSelector, } from './resolveValidationLocales.js' +type ClassifiedValidationError = { + error: ValidationFieldError + isLocalized: boolean +} + /** * Clones the caller's request into one scoped to `validate`, resolves the selected locales, runs * `runPass` once per locale against an independent request/data clone, and aggregates the field @@ -31,7 +39,6 @@ export async function runLocaleScopedValidation({ req, runPass, user, - validationDataLocale, }: { context: RequestContext | undefined data: TData @@ -45,14 +52,13 @@ export async function runLocaleScopedValidation({ req: PayloadRequest }) => Promise user: null | undefined | User - validationDataLocale: string | undefined }): Promise { const baseReq = await createPayloadRequest({ - context: cloneValidationValue(context), + context: cloneValidationContext({ context }), fallbackLocale: false, payload, - req: cloneValidationRequest(req), - user: cloneValidationValue(user), + req: cloneValidationRequest({ request: req }), + user: cloneValidationUser({ user }), }) baseReq.operation = 'validate' const localeSelector = locale === undefined ? (baseReq.locale ?? null) : locale @@ -68,17 +74,9 @@ export async function runLocaleScopedValidation({ fallbackLocale: false, locale: validationLocale ?? undefined, payload, - req: cloneValidationRequest(baseReq), + req: cloneValidationRequest({ request: baseReq }), }) - const validationCandidateData = cloneValidationValue(data) - const validationData: TData = - validationDataLocale && validationLocale !== validationDataLocale && validationCandidateData - ? (projectNonLocalizedData({ - configBlockReferences: payload.config.blocks, - data: validationCandidateData as JsonObject, - fields, - }) as TData) - : validationCandidateData + const validationData = cloneValidationData({ data }) let mergedValidationData = validationData as JsonObject const result = await runPass({ @@ -89,21 +87,26 @@ export async function runLocaleScopedValidation({ req: localeReq, }) - return { data: mergedValidationData, result } + return result.errors.map((error) => ({ + error, + isLocalized: isValidationErrorPathLocalized({ + configBlockReferences: payload.config.blocks, + data: mergedValidationData, + fields, + path: error.path, + }), + })) }, }) - const rawErrors = results.flatMap(({ result }) => result.errors) + const classifiedErrors = results.flat() + const rawErrors = classifiedErrors.map(({ error }) => error) // A non-localized field carries one shared value, so every locale pass validates it // identically and would otherwise report the same failure once per resolved locale. const errors = locales.length > 1 ? dedupeNonLocalizedFieldErrors({ - configBlockReferences: payload.config.blocks, - errors: results.flatMap(({ data: validationData, result }) => - result.errors.map((error) => ({ data: validationData, error })), - ), - fields, + errors: classifiedErrors, }) : rawErrors @@ -114,25 +117,14 @@ export async function runLocaleScopedValidation({ } function dedupeNonLocalizedFieldErrors({ - configBlockReferences, errors, - fields, }: { - configBlockReferences: SanitizedConfig['blocks'] - errors: { data: JsonObject; error: ValidationFieldError }[] - fields: Field[] + errors: ClassifiedValidationError[] }): ValidationFieldError[] { const seenNonLocalizedErrors = new Set() const deduped: ValidationFieldError[] = [] - for (const { data, error } of errors) { - const isLocalized = isValidationErrorPathLocalized({ - configBlockReferences, - data, - fields, - path: error.path, - }) - + for (const { error, isLocalized } of errors) { if (isLocalized) { deduped.push(error) continue diff --git a/packages/payload/src/utilities/runValidationLifecycle.ts b/packages/payload/src/utilities/runValidationLifecycle.ts new file mode 100644 index 00000000000..a76f2489944 --- /dev/null +++ b/packages/payload/src/utilities/runValidationLifecycle.ts @@ -0,0 +1,160 @@ +import type { SanitizedCollectionConfig } from '../collections/config/types.js' +import type { SanitizedGlobalConfig } from '../globals/config/types.js' +import type { JsonObject, PayloadRequest } from '../types/index.js' +import type { ValidationResult } from '../types/validation.js' + +import { beforeChange } from '../fields/hooks/beforeChange/index.js' +import { beforeValidate } from '../fields/hooks/beforeValidate/index.js' +import { deepCopyObjectSimple } from './deepCopyObject.js' +import { deepMergeWithSourceArraysIgnoringUndefined } from './deepMerge.js' +import { toValidationResult } from './toValidationResult.js' + +type EntityArgs = + | { + collection: null + global: SanitizedGlobalConfig + id?: never + } + | { + collection: SanitizedCollectionConfig + global: null + id?: number | string + } + +type RunValidationLifecycleArgs = { + beforeValidation?: (args: { data: JsonObject }) => Promise | void + docWithLocales: JsonObject + incomingData: JsonObject | undefined + onValidationData?: (data: JsonObject) => void + originalDoc: JsonObject + overrideAccess: boolean + req: PayloadRequest + validateData?: (args: { data: JsonObject }) => Promise | void +} & EntityArgs + +/** + * Runs the hook and field-validation lifecycle shared by collection and global on-demand + * validation after the operation has loaded its source document. + */ +export async function runValidationLifecycle( + args: RunValidationLifecycleArgs, +): Promise { + const { + id, + beforeValidation, + collection, + docWithLocales, + global, + incomingData, + onValidationData, + originalDoc, + overrideAccess, + req, + validateData, + } = args + let data = deepCopyObjectSimple(incomingData ?? {}) + + try { + onValidationData?.(deepMergeWithSourceArraysIgnoringUndefined(originalDoc, data)) + await beforeValidation?.({ data }) + + data = await beforeValidate({ + id, + collection, + context: req.context, + data, + doc: originalDoc, + global, + operation: 'validate', + overrideAccess, + req, + }) + onValidationData?.(data) + + if (collection) { + for (const hook of collection.hooks.beforeValidate ?? []) { + data = + (await hook({ + collection, + context: req.context, + data, + operation: 'validate', + originalDoc, + req, + })) || data + } + + for (const hook of collection.hooks.beforeChange ?? []) { + data = + (await hook({ + collection, + context: req.context, + data, + operation: 'validate', + originalDoc, + req, + })) || data + } + } else { + for (const hook of global.hooks.beforeValidate ?? []) { + data = + (await hook({ + context: req.context, + data, + global, + operation: 'validate', + originalDoc, + overrideAccess, + req, + })) || data + } + + for (const hook of global.hooks.beforeChange ?? []) { + data = + (await hook({ + context: req.context, + data, + global, + operation: 'validate', + originalDoc, + overrideAccess, + req, + })) || data + } + } + + onValidationData?.(data) + + let processedData = data + + await beforeChange({ + id, + collection, + context: req.context, + data: id === undefined ? data : { ...data, id }, + doc: originalDoc, + docWithLocales, + global, + onDataProcessed: (result) => { + processedData = result + }, + operation: 'validate', + overrideAccess, + req, + }) + + const validationData = deepMergeWithSourceArraysIgnoringUndefined( + originalDoc, + processedData, + ) + onValidationData?.(validationData) + await validateData?.({ data: validationData }) + } catch (error) { + return toValidationResult({ error, req }) + } + + return { + errors: [], + valid: true, + } +} diff --git a/packages/payload/src/utilities/toValidationResult.ts b/packages/payload/src/utilities/toValidationResult.ts index 61b71fb578a..4b3cb0fd1f0 100644 --- a/packages/payload/src/utilities/toValidationResult.ts +++ b/packages/payload/src/utilities/toValidationResult.ts @@ -1,5 +1,5 @@ -import type { ValidationResult } from '../collections/operations/local/validate.js' import type { PayloadRequest } from '../types/index.js' +import type { ValidationResult } from '../types/validation.js' import { ValidationError } from '../errors/index.js' diff --git a/test/types/types.spec.ts b/test/types/types.spec.ts index 1c89107b010..72626d29a3d 100644 --- a/test/types/types.spec.ts +++ b/test/types/types.spec.ts @@ -25,6 +25,7 @@ import type { FieldClientProps, FieldErrorServerProps, FieldHookArgs, + FieldOperation, FieldPermissions, FieldServerProps, GeneratedTypes, @@ -41,6 +42,7 @@ import type { MeOperationResult, NamedGroupField, NamedTab, + Operation, PaginatedDocs, PayloadClientComponentProps, PayloadRequest, @@ -132,6 +134,9 @@ describe('Types testing', () => { describe('validate operation types', () => { test('should expose beforeValidate operations', () => { expect().type.toBe<'create' | 'update' | 'validate'>() + expect().type.toBe<'create' | 'read' | 'update' | 'validate'>() + expect().type.toBe<'create' | 'delete' | 'read' | 'update' | 'validate'>() + expect().type.toBe() }) test('should expose validate only to validation lifecycle types', () => { diff --git a/test/validate/int.spec.ts b/test/validate/int.spec.ts index b6bc41916f3..02f27461d7a 100644 --- a/test/validate/int.spec.ts +++ b/test/validate/int.spec.ts @@ -4,7 +4,7 @@ import { buildEditorState } from '@payloadcms/richtext-lexical' import { randomUUID } from 'crypto' import fs from 'fs/promises' import path from 'path' -import { createPayloadRequest } from 'payload' +import { createPayloadRequest, ValidationError } from 'payload' import { expect } from 'vitest' import { test } from '../__helpers/int/vitest.js' @@ -78,6 +78,81 @@ test.suite('validate Local API', { config: './config.ts' }, () => { }) test.describe('collections', () => { + test('should not add locale metadata to normal create validation errors', async ({ + payload, + }) => { + let validationError: unknown + + try { + await payload.create({ + collection: publishCollectionSlug, + data: { + ...getPublishCollectionLocaleData({ title: '' }), + localizedArray: 'invalid', + } as never, + locale: 'en', + overrideAccess: true, + }) + } catch (error) { + validationError = error + } + + expect(validationError).toBeInstanceOf(ValidationError) + expect((validationError as ValidationError).data.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: 'localizedArray' }), + expect.objectContaining({ path: 'title' }), + ]), + ) + expect( + (validationError as ValidationError).data.errors.every( + (fieldError) => fieldError.locale === undefined, + ), + ).toBe(true) + expect((validationError as ValidationError).message).not.toContain('[en]') + }) + + test('should not add locale metadata to normal update validation errors', async ({ + payload, + }) => { + const stored = await payload.create({ + collection: publishCollectionSlug, + data: getPublishCollectionLocaleData({ title: 'Stored title' }), + locale: 'en', + overrideAccess: true, + }) + let validationError: unknown + + try { + await payload.update({ + id: stored.id, + collection: publishCollectionSlug, + data: { + localizedArray: 'invalid', + title: '', + } as never, + locale: 'en', + overrideAccess: true, + }) + } catch (error) { + validationError = error + } + + expect(validationError).toBeInstanceOf(ValidationError) + expect((validationError as ValidationError).data.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: 'localizedArray' }), + expect.objectContaining({ path: 'title' }), + ]), + ) + expect( + (validationError as ValidationError).data.errors.every( + (fieldError) => fieldError.locale === undefined, + ), + ).toBe(true) + expect((validationError as ValidationError).message).not.toContain('[en]') + }) + test('should report a configured unique field conflict', async ({ payload }) => { await payload.create({ collection: validationUniqueCollectionSlug, @@ -628,57 +703,6 @@ test.suite('validate Local API', { config: './config.ts' }, () => { ).resolves.toBeDefined() }) - test('should ignore internal projection flags passed to the public collection validate API', async ({ - payload, - }) => { - const stored = await payload.create({ - collection: validationCollectionSlug, - data: { - summary: 'stored summary', - title: 'Stored title', - }, - locale: 'en', - overrideAccess: true, - }) - - const result = await payload.validate({ - id: stored.id, - collection: validationCollectionSlug, - data: { - summary: 'candidate summary', - title: 'Candidate title', - }, - locale: ['en', 'es'], - validationDataLocale: 'en', - } as never) - - expect(result).toEqual({ - errors: [], - valid: true, - }) - }) - - test('should ignore internal trash-source flags passed to the public collection validate API', async ({ - payload, - }) => { - const stored = await seedPublishCollection({ - de: 'German optional', - deletedAt: new Date().toISOString(), - en: 'English draft', - es: 'Spanish valid', - payload, - }) - - await expect( - payload.validate({ - id: stored.id, - collection: publishCollectionSlug, - locale: 'en', - validationTrash: true, - } as never), - ).rejects.toThrow(/not found/i) - }) - test('should resolve all to every available locale through locale filtering', async ({ payload, }) => { @@ -2020,25 +2044,6 @@ test.suite('validate Local API', { config: './config.ts' }, () => { expect(globalValidationSourceEvents).toEqual([]) }) - test('should ignore internal projection flags passed to the public global validate API', async ({ - payload, - }) => { - const result = await payload.validateGlobal({ - slug: validationGlobalSlug, - data: { - summary: 'candidate summary', - title: 'Candidate title', - }, - locale: ['en', 'es'], - validationDataLocale: 'en', - } as never) - - expect(result).toEqual({ - errors: [], - valid: true, - }) - }) - test('should validate valid partial global data without persisting it', async ({ payload }) => { const req = { operation: 'read', From 8e83af98b740617d720e4345519d563b08896145 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Fri, 2 Oct 2026 18:36:58 +0100 Subject: [PATCH 12/12] chore: improve before change argument typing Written with AI --- packages/payload/src/globals/operations/update.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/payload/src/globals/operations/update.ts b/packages/payload/src/globals/operations/update.ts index 97d55a59f15..6c38c4857a3 100644 --- a/packages/payload/src/globals/operations/update.ts +++ b/packages/payload/src/globals/operations/update.ts @@ -1,6 +1,7 @@ import type { DeepPartial } from 'ts-essentials' import type { FindOptions } from '../../collections/operations/local/find.js' +import type { Args as BeforeChangeArgs } from '../../fields/hooks/beforeChange/index.js' import type { GlobalSlug, JsonObject } from '../../index.js' import type { PayloadRequest, @@ -351,10 +352,10 @@ export const updateOperation = async < docWithLocales: globalJSON, fieldsToValidate: submittedTopLevelFieldNames, global: globalConfig, - operation: 'update' as const, + operation: 'update', req, skipValidation: isSavingDraft && !hasDraftValidationEnabled(globalConfig), - } + } satisfies BeforeChangeArgs let statusFieldValue: unknown