From 20f6c2451e989d6ff5bcb1f6db7a4d9ece6745e3 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Wed, 29 Jul 2026 10:59:32 -0700 Subject: [PATCH 1/4] SanitizedCollectionConfig --- packages/payload/src/auth/executeAccess.ts | 2 +- .../src/collections/config/defaults.ts | 60 ++++---- .../payload/src/collections/config/types.ts | 130 ++++++++++-------- .../src/utilities/appendNonTrashedFilter.ts | 2 +- 4 files changed, 107 insertions(+), 87 deletions(-) diff --git a/packages/payload/src/auth/executeAccess.ts b/packages/payload/src/auth/executeAccess.ts index 558793a60b1..0f9251de471 100644 --- a/packages/payload/src/auth/executeAccess.ts +++ b/packages/payload/src/auth/executeAccess.ts @@ -12,7 +12,7 @@ type OperationArgs = { } export const executeAccess = async ( { id, data, disableErrors, isReadingStaticFile = false, req }: OperationArgs, - access: Access, + access?: Access, ): Promise => { if (access) { const resolvedConstraint = await access({ diff --git a/packages/payload/src/collections/config/defaults.ts b/packages/payload/src/collections/config/defaults.ts index b6805d2c1d5..60a71d938c9 100644 --- a/packages/payload/src/collections/config/defaults.ts +++ b/packages/payload/src/collections/config/defaults.ts @@ -1,5 +1,5 @@ import type { IncomingAuthType, LoginWithUsernameOptions } from '../../auth/types.js' -import type { CollectionConfig } from './types.js' +import type { CollectionConfig, SanitizedCollectionConfig } from './types.js' import { defaultAccess } from '../../auth/defaultAccess.js' @@ -32,6 +32,7 @@ export const defaults: Partial = { hooks: { afterChange: [], afterDelete: [], + afterError: [], afterForgotPassword: [], afterLogin: [], afterLogout: [], @@ -55,14 +56,16 @@ export const defaults: Partial = { } export const addDefaultsToCollectionConfig = (collection: CollectionConfig): CollectionConfig => { + const access = collection.access + collection.access = { - create: defaultAccess, - delete: defaultAccess, - read: defaultAccess, - unlock: defaultAccess, - update: defaultAccess, - ...(collection.access || {}), - } + ...access, + create: access?.create ?? defaultAccess, + delete: access?.delete ?? defaultAccess, + read: access?.read ?? defaultAccess, + unlock: access?.unlock ?? defaultAccess, + update: access?.update ?? defaultAccess, + } satisfies SanitizedCollectionConfig['access'] collection.admin = { components: {}, @@ -84,26 +87,29 @@ export const addDefaultsToCollectionConfig = (collection: CollectionConfig): Col collection.fields = collection.fields ?? [] collection.hierarchy = collection.hierarchy ?? false + const hooks = collection.hooks + collection.hooks = { - afterChange: [], - afterDelete: [], - afterForgotPassword: [], - afterLogin: [], - afterLogout: [], - afterMe: [], - afterOperation: [], - afterRead: [], - afterRefresh: [], - beforeChange: [], - beforeDelete: [], - beforeLogin: [], - beforeOperation: [], - beforeRead: [], - beforeValidate: [], - me: [], - refresh: [], - ...(collection.hooks || {}), - } + ...hooks, + afterChange: hooks?.afterChange ?? [], + afterDelete: hooks?.afterDelete ?? [], + afterError: hooks?.afterError ?? [], + afterForgotPassword: hooks?.afterForgotPassword ?? [], + afterLogin: hooks?.afterLogin ?? [], + afterLogout: hooks?.afterLogout ?? [], + afterMe: hooks?.afterMe ?? [], + afterOperation: hooks?.afterOperation ?? [], + afterRead: hooks?.afterRead ?? [], + afterRefresh: hooks?.afterRefresh ?? [], + beforeChange: hooks?.beforeChange ?? [], + beforeDelete: hooks?.beforeDelete ?? [], + beforeLogin: hooks?.beforeLogin ?? [], + beforeOperation: hooks?.beforeOperation ?? [], + beforeRead: hooks?.beforeRead ?? [], + beforeValidate: hooks?.beforeValidate ?? [], + me: hooks?.me ?? [], + refresh: hooks?.refresh ?? [], + } satisfies SanitizedCollectionConfig['hooks'] collection.timestamps = collection.timestamps ?? true collection.upload = collection.upload ?? false diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index ada4fcfde5e..a05b92716b5 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { GraphQLInputObjectType, GraphQLNonNull, GraphQLObjectType } from 'graphql' -import type { DeepRequired, IsAny, MarkOptional } from 'ts-essentials' +import type { IsAny, MarkOptional } from 'ts-essentials' import type { CustomUpload, ViewTypes } from '../../admin/types.js' import type { Arguments as MeArguments } from '../../auth/operations/me.js' @@ -531,6 +531,48 @@ export type CollectionAdminOptions = { useAsTitle?: string } +type CollectionAccess = { + admin?: ({ req }: { req: PayloadRequest }) => boolean | Promise + create?: Access + delete?: Access + read?: Access + readVersions?: Access + unlock?: Access + update?: Access +} + +type CollectionHooks = { + afterChange?: AfterChangeHook[] + afterDelete?: AfterDeleteHook[] + afterError?: AfterErrorHook[] + afterForgotPassword?: AfterForgotPasswordHook[] + afterLogin?: AfterLoginHook[] + afterLogout?: AfterLogoutHook[] + afterMe?: AfterMeHook[] + afterOperation?: AfterOperationHook[] + afterRead?: AfterReadHook[] + afterRefresh?: AfterRefreshHook[] + beforeChange?: BeforeChangeHook[] + beforeDelete?: BeforeDeleteHook[] + beforeLogin?: BeforeLoginHook[] + beforeOperation?: BeforeOperationHook[] + beforeRead?: BeforeReadHook[] + beforeValidate?: BeforeValidateHook[] + /** + /** + * Use the `me` hook to control the `me` operation. + * Here, you can optionally instruct the me operation to return early, + * and skip its default logic. + */ + me?: MeHook[] + /** + * Use the `refresh` hook to control the refresh operation. + * Here, you can optionally instruct the refresh operation to return early, + * and skip its default logic. + */ + refresh?: RefreshHook[] +} + /** Manage all aspects of a data collection */ export type CollectionConfig = { /** @@ -541,15 +583,7 @@ export type CollectionConfig = { /** * Access control */ - access?: { - admin?: ({ req }: { req: PayloadRequest }) => boolean | Promise - create?: Access - delete?: Access - read?: Access - readVersions?: Access - unlock?: Access - update?: Access - } + access?: CollectionAccess /** * Collection admin options */ @@ -640,37 +674,7 @@ export type CollectionConfig = { /** * Hooks to modify Payload functionality */ - hooks?: { - afterChange?: AfterChangeHook[] - afterDelete?: AfterDeleteHook[] - afterError?: AfterErrorHook[] - afterForgotPassword?: AfterForgotPasswordHook[] - afterLogin?: AfterLoginHook[] - afterLogout?: AfterLogoutHook[] - afterMe?: AfterMeHook[] - afterOperation?: AfterOperationHook[] - afterRead?: AfterReadHook[] - afterRefresh?: AfterRefreshHook[] - beforeChange?: BeforeChangeHook[] - beforeDelete?: BeforeDeleteHook[] - beforeLogin?: BeforeLoginHook[] - beforeOperation?: BeforeOperationHook[] - beforeRead?: BeforeReadHook[] - beforeValidate?: BeforeValidateHook[] - /** - /** - * Use the `me` hook to control the `me` operation. - * Here, you can optionally instruct the me operation to return early, - * and skip its default logic. - */ - me?: MeHook[] - /** - * Use the `refresh` hook to control the refresh operation. - * Here, you can optionally instruct the refresh operation to return early, - * and skip its default logic. - */ - refresh?: RefreshHook[] - } + hooks?: CollectionHooks /** * Define compound indexes for this collection. * This can be used to either speed up querying/sorting by 2 or more fields at the same time or @@ -788,28 +792,36 @@ export type SanitizedJoins = { } /** - * @todo remove the `DeepRequired` in v4. - * We don't actually guarantee that all properties are set when sanitizing configs. + * Properties populated during sanitization are redefined below. All other collection properties + * preserve their incoming optionality. */ export interface SanitizedCollectionConfig extends Omit< - DeepRequired, - | 'admin' - | 'auth' - | 'endpoints' - | 'fields' - | 'folder' - | 'folders' - | 'hierarchy' - | 'slug' - | 'tags' - | 'upload' - | 'versions' - > { - admin: CollectionAdminOptions + CollectionConfig, + | '_sanitized' + | 'access' + | 'admin' + | 'auth' + | 'custom' + | 'endpoints' + | 'folder' + | 'folders' + | 'hierarchy' + | 'hooks' + | 'indexes' + | 'labels' + | 'slug' + | 'tags' + | 'timestamps' + | 'upload' + | 'versions' + >, + Required> { + _sanitized: true + access: Pick & + Required> auth: Auth endpoints: Endpoint[] | false - fields: Field[] /** * Fields in the database schema structure * Rows / collapsible / tabs w/o name `fields` merged to top, UIs are excluded @@ -819,10 +831,12 @@ export interface SanitizedCollectionConfig * Hierarchy configuration (when collection is a hierarchy type like folders or tags) */ hierarchy: false | SanitizedHierarchyConfig + hooks: Required /** * Object of collections to join 'Join Fields object keyed by collection */ joins: SanitizedJoins + labels: Required> /** * List of all polymorphic join fields */ diff --git a/packages/payload/src/utilities/appendNonTrashedFilter.ts b/packages/payload/src/utilities/appendNonTrashedFilter.ts index dc88ecc50ed..c8deb5a337a 100644 --- a/packages/payload/src/utilities/appendNonTrashedFilter.ts +++ b/packages/payload/src/utilities/appendNonTrashedFilter.ts @@ -7,7 +7,7 @@ export const appendNonTrashedFilter = ({ where, }: { deletedAtPath?: string - enableTrash: boolean + enableTrash?: boolean trash?: boolean where: Where }): Where => { From 103e2ba930d5a12f24128375dab9776f626d694e Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Wed, 29 Jul 2026 11:24:08 -0700 Subject: [PATCH 2/4] auth --- packages/payload/src/auth/types.ts | 48 +++++++++++++------ .../src/collections/config/defaults.ts | 34 +++++++------ .../src/collections/config/sanitize.ts | 24 +--------- 3 files changed, 54 insertions(+), 52 deletions(-) diff --git a/packages/payload/src/auth/types.ts b/packages/payload/src/auth/types.ts index 4d400824cf4..736264e871f 100644 --- a/packages/payload/src/auth/types.ts +++ b/packages/payload/src/auth/types.ts @@ -1,5 +1,3 @@ -import type { DeepRequired } from 'ts-essentials' - import type { CollectionSlug, GlobalSlug, Payload, User } from '../index.js' import type { PayloadRequest, Where } from '../types/index.js' @@ -216,15 +214,17 @@ export type LoginWithUsernameOptions = requireUsername?: boolean } +type AuthCookies = { + domain?: string + sameSite?: 'Lax' | 'None' | 'Strict' | boolean + secure?: boolean +} + export interface IncomingAuthType { /** * Set cookie options, including secure, sameSite, and domain. For advanced users. */ - cookies?: { - domain?: string - sameSite?: 'Lax' | 'None' | 'Strict' | boolean - secure?: boolean - } + cookies?: AuthCookies /** * How many levels deep a user document should be populated when creating the JWT and binding the user to the req. Defaults to 0 and should only be modified if absolutely necessary, as this will affect performance. * @default 0 @@ -316,14 +316,32 @@ export type VerifyConfig = { } export interface Auth - extends Omit, 'forgotPassword' | 'loginWithUsername' | 'verify'> { - forgotPassword?: { - expiration?: number - generateEmailHTML?: GenerateForgotPasswordEmailHTML - generateEmailSubject?: GenerateForgotPasswordEmailSubject - } - loginWithUsername: false | LoginWithUsernameOptions - verify?: boolean | VerifyConfig + extends Omit< + IncomingAuthType, + | 'cookies' + | 'forgotPassword' + | 'lockTime' + | 'loginWithUsername' + | 'maxLoginAttempts' + | 'strategies' + | 'tokenExpiration' + | 'useSessions' + | 'verify' + >, + Required< + Pick< + IncomingAuthType, + | 'forgotPassword' + | 'lockTime' + | 'maxLoginAttempts' + | 'strategies' + | 'tokenExpiration' + | 'useSessions' + | 'verify' + > + > { + cookies: Pick & Required> + loginWithUsername: false | Required } export function hasWhereAccessResult(result: boolean | Where): result is Where { diff --git a/packages/payload/src/collections/config/defaults.ts b/packages/payload/src/collections/config/defaults.ts index 60a71d938c9..4cde8136b24 100644 --- a/packages/payload/src/collections/config/defaults.ts +++ b/packages/payload/src/collections/config/defaults.ts @@ -1,4 +1,4 @@ -import type { IncomingAuthType, LoginWithUsernameOptions } from '../../auth/types.js' +import type { Auth, IncomingAuthType, LoginWithUsernameOptions } from '../../auth/types.js' import type { CollectionConfig, SanitizedCollectionConfig } from './types.js' import { defaultAccess } from '../../auth/defaultAccess.js' @@ -120,16 +120,20 @@ export const addDefaultsToCollectionConfig = (collection: CollectionConfig): Col return collection } -export const addDefaultsToAuthConfig = (auth: IncomingAuthType): IncomingAuthType => { +export const addDefaultsToAuthConfig = (auth: IncomingAuthType): Auth => { auth.cookies = { - sameSite: 'Lax', - secure: false, ...(auth.cookies || {}), - } + sameSite: auth.cookies?.sameSite ?? 'Lax', + secure: auth.cookies?.secure ?? false, + } satisfies Auth['cookies'] auth.forgotPassword = auth.forgotPassword ?? {} auth.lockTime = auth.lockTime ?? 600000 // 10 minutes - auth.loginWithUsername = auth.loginWithUsername ?? false + auth.loginWithUsername = auth.loginWithUsername + ? addDefaultsToLoginWithUsernameConfig( + auth.loginWithUsername === true ? {} : auth.loginWithUsername, + ) + : false auth.maxLoginAttempts = auth.maxLoginAttempts ?? 5 auth.tokenExpiration = auth.tokenExpiration ?? 7200 auth.useSessions = auth.useSessions ?? true @@ -140,13 +144,13 @@ export const addDefaultsToAuthConfig = (auth: IncomingAuthType): IncomingAuthTyp auth.verify = {} } - return auth + return auth as Auth } /** * @deprecated - remove in 4.0. This is error-prone, as mutating this object will affect any objects that use the defaults as a base. */ -export const loginWithUsernameDefaults: LoginWithUsernameOptions = { +export const loginWithUsernameDefaults: Required = { allowEmailLogin: false, requireEmail: false, requireUsername: true, @@ -154,10 +158,12 @@ export const loginWithUsernameDefaults: LoginWithUsernameOptions = { export const addDefaultsToLoginWithUsernameConfig = ( loginWithUsername: LoginWithUsernameOptions, -): LoginWithUsernameOptions => +): Required => ({ - allowEmailLogin: false, - requireEmail: false, - requireUsername: true, - ...(loginWithUsername || {}), - }) as LoginWithUsernameOptions + ...loginWithUsername, + allowEmailLogin: loginWithUsername.allowEmailLogin ?? false, + requireEmail: loginWithUsername.requireEmail ?? false, + requireUsername: loginWithUsername.allowEmailLogin + ? (loginWithUsername.requireUsername ?? true) + : true, + }) as Required diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index c58d1aa66c4..70df7924f26 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -24,11 +24,7 @@ import { traverseForLocalizedFields } from '../../utilities/traverseForLocalized import { baseVersionFields } from '../../versions/baseFields.js' import { versionDefaults } from '../../versions/defaults.js' import { defaultCollectionEndpoints } from '../endpoints/index.js' -import { - addDefaultsToAuthConfig, - addDefaultsToCollectionConfig, - addDefaultsToLoginWithUsernameConfig, -} from './defaults.js' +import { addDefaultsToAuthConfig, addDefaultsToCollectionConfig } from './defaults.js' import { sanitizeCompoundIndexes } from './sanitizeCompoundIndexes.js' import { validateUseAsTitle } from './useAsTitle.js' @@ -333,24 +329,6 @@ export const sanitizeCollection = async ( // disable duplicate for auth enabled collections by default sanitized.disableDuplicate = sanitized.disableDuplicate ?? true - if (sanitized.auth.loginWithUsername) { - if (sanitized.auth.loginWithUsername === true) { - sanitized.auth.loginWithUsername = addDefaultsToLoginWithUsernameConfig({}) - } else { - const loginWithUsernameWithDefaults = addDefaultsToLoginWithUsernameConfig( - sanitized.auth.loginWithUsername, - ) - - // if allowEmailLogin is false, requireUsername must be true - if (loginWithUsernameWithDefaults.allowEmailLogin === false) { - loginWithUsernameWithDefaults.requireUsername = true - } - sanitized.auth.loginWithUsername = loginWithUsernameWithDefaults - } - } else { - sanitized.auth.loginWithUsername = false - } - if (!collection?.admin?.useAsTitle) { sanitized.admin!.useAsTitle = sanitized.auth.loginWithUsername ? 'username' : 'email' } From 3b1004a03e8a977bd525d404557109ca8bad5024 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Wed, 29 Jul 2026 11:33:58 -0700 Subject: [PATCH 3/4] v4.mdx --- docs/migration-guide/v4.mdx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/migration-guide/v4.mdx b/docs/migration-guide/v4.mdx index 865baba4555..814a9488eaa 100644 --- a/docs/migration-guide/v4.mdx +++ b/docs/migration-guide/v4.mdx @@ -230,6 +230,19 @@ import type { DefaultValue } from 'payload' If you only use `DefaultValue` to type a `defaultValue` property passed into a Collection, Global, or Field config, no changes are needed. +### Sanitized collection and auth config types now match runtime defaults + +`SanitizedCollectionConfig` and its auth type no longer use `DeepRequired`. This reduces TypeScript checker work and makes optionality match runtime defaults. + +Properties Payload does not default, such as `access.readVersions`, `auth.cookies.domain`, `auth.useAPIKey`, and `auth.depth`, now require undefined handling: + +```ts +if (collection.access.readVersions) await collection.access.readVersions(args) +const useAPIKey = collection.auth.useAPIKey ?? false +``` + +Properties sanitization does fill, including standard access functions, hook arrays, cookie defaults, and normalized auth options, are now required. Use `CollectionConfig` or `IncomingAuthType` for input and `SanitizedCollectionConfig` after sanitization. + ### User types consolidated into `User` and `AuthenticatedUser` Payload's user types have been consolidated so each one has a clear job. This completes a cleanup already planned in 3.x: `UntypedUser` was deprecated for removal in 4.0, and `TypedUser` was marked to be renamed to `User` in 4.0. From c978686cff3a952a8c81d524fee322ab464a424d Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Wed, 29 Jul 2026 11:45:33 -0700 Subject: [PATCH 4/4] lint --- packages/payload/src/auth/getLoginOptions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/payload/src/auth/getLoginOptions.ts b/packages/payload/src/auth/getLoginOptions.ts index 490fcc2f099..4a4fdb6be2d 100644 --- a/packages/payload/src/auth/getLoginOptions.ts +++ b/packages/payload/src/auth/getLoginOptions.ts @@ -7,7 +7,7 @@ export const getLoginOptions = ( canLoginWithUsername: boolean } => { return { - canLoginWithEmail: !loginWithUsername || loginWithUsername.allowEmailLogin!, + canLoginWithEmail: !loginWithUsername || loginWithUsername.allowEmailLogin, canLoginWithUsername: Boolean(loginWithUsername), } }