Commit Graph

2607 Commits

Author SHA1 Message Date
Patrik Kozak 748cced830 Merge branch 'main' of github.com:payloadcms/payload into feat/authorship 2026-08-24 10:43:11 -04:00
Patrik Kozak 3b8f361833 feat: add authorship field builders, overrides, and graphql/raw-field tests 2026-08-24 10:42:59 -04:00
Jessica Rynkar 3aa4043b0a feat: stubs out cohort telemetry (#17876)
Adds `projectCohorts` and `figmaProduct` groups to our captured
telemetry.

Note: the `enterprise` cohort is a partial solution currently (only
captures enterprise clients using enterprise plugins) - we will be
strengthening this with an additional identifier soon.
2026-08-24 10:37:04 +01:00
Alessio Gravili eabe959f14 feat: add some, none, and every operators to has-many relationship queries (#17838)
This PR adds `some`, `none`, and `every` operators for non-polymorphic,
has-many relationship and upload fields.

These operators make it possible to apply a complete nested `where`
query to the related documents.

Support is intentionally API-only in this PR. The Admin UI Where Builder
does not expose `some`, `none`, or `every`. This matches existing
dot-notation relationship queries, which can query related document
properties through the APIs but cannot be constructed in the generic
Where Builder. Supporting these in the UI Where Builder would require a
recursive UI for building the nested related-document `where` query and
can be handled separately.

This work also fixes #16109 by ensuring that Drizzle uses the current
related table alias when a query traverses a transitive has-many join.

## Problem

Payload already supports querying fields on related documents with dot
notation. For example, given a `directors` collection with a has-many
`movies` relationship, this query finds directors who directed at least
one movie named `recalls`:

```ts
const result = await payload.find({
  collection: 'directors',
  where: {
    'movies.name': {
      equals: 'recalls',
    },
  },
})
```

This behaves like the following JavaScript expression:

```ts
movies.some((movie) => movie.name === 'recalls')
```

Before this PR, it was tempting to reverse the condition with
`not_equals`:

```ts
const result = await payload.find({
  collection: 'directors',
  where: {
    'movies.name': {
      not_equals: 'recalls',
    },
  },
})
```

However, that query means “find a director with at least one movie whose
name is not `recalls`.” It behaves like this:

```ts
movies.some((movie) => movie.name !== 'recalls')
```

A director related to both `recalls` and `electric-cars` therefore still
matches. The `electric-cars` relationship row satisfies `not_equals`,
even though another relationship row points to `recalls`.

There was no query syntax for saying that no related document may match
a condition.

## New API

The new `none` operator expresses the intended query directly:

```ts
const result = await payload.find({
  collection: 'directors',
  where: {
    movies: {
      none: {
        name: {
          equals: 'recalls',
        },
      },
    },
  },
})
```

This behaves like the following JavaScript expression:

```ts
!movies.some((movie) => movie.name === 'recalls')
```

A director related to both `recalls` and `electric-cars` is excluded. A
director related only to `recalls` is also excluded. A director related
only to `electric-cars`, or a director with no movies, is included.

The same nested query structure supports `some`:

```ts
const result = await payload.find({
  collection: 'directors',
  where: {
    movies: {
      some: {
        name: {
          equals: 'recalls',
        },
      },
    },
  },
})
```

It also supports `every`:

```ts
const result = await payload.find({
  collection: 'directors',
  where: {
    movies: {
      every: {
        rating: {
          greater_than_equal: 4,
        },
      },
    },
  },
})
```

## Operator behavior

`some` matches when at least one related document matches the nested
query.

`none` matches when no related document matches the nested query.

`every` matches when all related documents match the nested query.

An empty relationship does not match `some`. It does match `none` and
`every`. This follows normal JavaScript array behavior, where
`[].some(...)` is false and `[].every(...)` is true.

The nested value is a complete Payload `where` query. It can contain
multiple field conditions, `and`, `or`, direct ID conditions, and
supported nested relationship queries. All conditions inside one `some`,
`none`, or `every` block are evaluated against the same related
document.

## Existing dot-notation behavior

Existing dot-notation relationship queries remain supported and are
unchanged. A positive dotted query continues to behave like an implicit
`some` query:

```ts
where: {
  'movies.name': {
    equals: 'recalls',
  },
}
```

The equivalent explicit query is:

```ts
where: {
  movies: {
    some: {
      name: {
        equals: 'recalls',
      },
    },
  },
}
```

The existing meaning of dotted `not_equals` is also unchanged.
Applications that need “no related document matches” can now use `none`
without changing the behavior of existing queries.

## Implementation

The shared Payload query types now distinguish normal field operators
from has-many relationship operators. Normal operators accept a JSON
value, while `some`, `none`, and `every` accept another `Where` object.
Query validation confirms that these operators are used on a supported
has-many field and then validates the nested query against the related
collection.

The Drizzle adapter builds a correlated subquery for the relationship.
`some` checks that a matching relationship row exists. `none` checks
that no matching relationship row exists. `every` checks that no
relationship row points to a related document that fails the nested
query. The nested query is still parsed by the normal Drizzle query
builder, so existing field operators and deeper relationship paths are
reused.

The MongoDB adapter builds the nested query against the related
collection and retrieves the matching related document IDs. `some` uses
`$in` with matching IDs, while `none` uses `$nin`. For `every`, MongoDB
finds related documents that do not match and uses `$nin` with those
IDs.

The GraphQL schema exposes the three operators on supported fields. REST
requests use the same nested query structure through the existing
query-string parser. Virtual relationship paths, localized
relationships, arrays, and blocks continue through the existing
path-resolution logic.

The MCP `where` schema keeps value operators and relationship operators
separate. It validates `some`, `none`, and `every` with the same
recursive `where` schema used for `and` and `or` groups.

## Supported fields

The new operators are supported on relationship and upload fields where
`hasMany` is enabled and `relationTo` contains one collection slug.

Singular relationships are intentionally excluded because their existing
dotted queries already operate on at most one related document.
Polymorphic relationships are also excluded because a single nested
query cannot be validated against multiple potentially different
collection schemas. Unsupported uses return the normal query validation
error with the operator path.
2026-08-21 12:32:13 +01:00
Alessio Gravili a872aad1a3 refactor!: improve access defaults for jobs (#17768)
Generic create, read, update, and delete access on `payload-jobs` is now
disabled by default.

The Jobs documentation now explains the difference between dedicated
Jobs APIs and raw collection CRUD.

## Why?

Job documents are internal system data, so generic collection access
should be opt-in. Dedicated APIs such as `payload.jobs.*`, `/run`, and
`/handle-schedules` remain the recommended ways to work with jobs.

Projects that intentionally expose the jobs collection can still
configure its access through `jobsCollectionOverrides`.

## Breaking changes

Projects that use REST or GraphQL CRUD directly on `payload-jobs`, or
Local API CRUD with `overrideAccess: false`, will now be denied by
default. The dedicated `payload.jobs.*` APIs and trusted Local API calls
with access override enabled continue to work as before.

If your project intentionally exposes job documents, add the required
collection access through `jobsCollectionOverrides`. Prefer read-only
access for trusted administrators because job documents can contain
sensitive information.
2026-08-19 17:20:16 -04:00
Patrik Kozak f0dbca9984 Merge branch 'main' of github.com:payloadcms/payload into feat/authorship 2026-08-17 13:07:20 -04:00
Sasha 07e8ef3db6 fix: prevent read operations from rolling back the caller's transaction (#17748)
Read operations never call `initTransaction`, so they never own a
transaction - but every one of them called `killTransaction(req)` in its
catch block. Because `req` is shared with the caller, a failed read
rolled back the caller's in-flight writes and deleted
`req.transactionID`.

When the caller swallows the error, the damage is silent. A hook doing
`payload.findByID(...).catch(() => null)` against a trashed doc (issue
#17723) kills the transaction opened by the surrounding `create`;
`create` sees no failure and goes on to `commitTransaction(req)`, which
no-ops because the adapter session is already gone. The client gets a
201 with a real doc id and a populated doc, the Postgres sequence
advances, and no row is ever written.

Removes the `killTransaction` call - and the resulting useless try/catch
- from the 14 operations that never open a transaction: collection
`find`/`findByID`/`count`/`countVersions`/`findVersions`/`findVersionByID`/`findDistinct`/
`docAccess`, their global equivalents, and `auth`/`access`. Errors that
propagate are still rolled back by the owning operation's own catch;
errors that a hook catches now leave the transaction intact so it
commits normally.

The diff is mostly re-indentation.

Documents the ownership rule on `killTransaction` so it isn't
reintroduced.

Fixes #17723

<!--

Thank you for the PR! Please go through the checklist below and make
sure you've completed all the steps.

Please review the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository if you haven't already.

## Which branch should this PR target?

- **`main`** — Payload v4 development. New features, enhancements, and
v4 bug fixes go here.
- **`3.x`** — Payload v3 maintenance. Bug fixes only. **New features are
not accepted against v3.** If this PR adds a feature to v3, please
retarget it at `main` (v4) instead.

The following items will ensure that your PR is handled as smoothly as
possible:

- PR Title must follow conventional commits format. For example, `feat:
my new feature`, `fix(plugin-seo): my fix`.
- Minimal description explained as if explained to someone not
immediately familiar with the code.
- Provide before/after screenshots or code diffs if applicable.
- Link any related issues/discussions from GitHub or Discord.
- Add review comments if necessary to explain to the reviewer the logic
behind a change

### What?

### Why?

### How?

Fixes #

-->
2026-08-17 09:15:19 -04:00
Elliot DeNolf 411bb4d60b fix: expose payload build child-process signal failures (#17781)
`payload build` now exits non-zero when the framework build _is killed_
unexpectedly by a signal. A signal kill never returned the appropriate
exit code, hiding the real failure.

Resolves
[PYLD-3469](https://linear.app/figma/issue/PYLD-3469/expose-payload-build-child-process-signal-failures)

## Root Cause

The spawn handler resolved `code ?? 0` on the child `exit` event. A
signal termination (SIGKILL, or an OOM kill) reports `code === null`.
The `?? 0` converted that null into exit code 0. The build reported
success, the deploy script continued, and it then failed later on a
missing `.next/standalone` directory. The signal was lost.

## Key Changes

- **Signal-aware exit handling**
- The handler now listens on `close` instead of `exit`. `close` also
waits for the child's inherited output streams to flush before
resolving.
- A present `signal` logs the signal name and resolves 1. A missing code
resolves `code ?? 1`, so an abnormal termination never reports success.

## Usage / Before-after

Before: a SIGKILL'd `next build` caused `payload build` to exit 0.
After: it exits 1 and logs `Build terminated by signal SIGKILL`.

## Tested Manually

- Terminal 1: `pnpm build`
- Terminal 2: `pgrep -f 'next-build \(v' | xargs kill -9` during next
build
- Verify terminal 2 has non-zero exit code: `echo $?`
2026-08-14 10:47:48 -04:00
German Jablonski ef69ab17ae feat: add base access control (#17662)
## Summary

Adds a top-level `baseAccess` config with separate `collections` and
`globals` access maps. This gives applications and plugins one
core-owned policy layer without iterating over resources or hardcoding
Payload-managed slugs.

## Motivation

This originated from Figma Make's need to enforce a read-only policy for
viewer users across every Payload resource. Its plugin currently has to
find resources and wrap their access callbacks, which is order-dependent
and can miss Payload-managed resources created during sanitization.

Plugins can currently apply a shared policy only by finding every
Collection and Global and wrapping each access callback. That is
order-dependent and can miss resources added by later plugins or by
Payload during sanitization, making omissions difficult to detect and
potentially security-sensitive.

`baseAccess` moves that composition into Payload so the same policy is
applied consistently to configured, plugin-provided, and
Payload-generated resources.

## Alternatives considered

- **Plugin ordering and callback wrapping:** still depends on when
resources are registered and cannot reliably cover resources created
during sanitization.
- **One boolean-only function:** simpler, but cannot express the `Where`
constraints supported by existing Collection and Global Access Control.
- **A new policies API:** more flexible, but introduces a larger new
abstraction when the existing access shapes already describe the
required operations.

## Changes

- Reuses `CollectionAccess` and `GlobalAccess` directly for
`baseAccess.collections` and `baseAccess.globals`
- Adds the resource `slug` to all Collection and Global Access Control
callback arguments, including `access.admin`
- Combines each configured base function with the matching resource
access function using AND semantics
- Applies the policy to resources created during sanitization, including
Payload-generated resources
- Resolves the base function from the current request so reused
sanitized resources cannot retain another config's policy
- Fails closed when either access function returns an invalid falsy
result
- Preserves `overrideAccess` behavior and existing resource-specific
access
- Keeps `baseAccess` out of the client config
- Rejects query constraints returned by collection base access for
create operations, which only support boolean access
- Documents the API, composition rules, scope, and Payload 4 migration
note

---------

Co-authored-by: German Jablonski <GermanJablo@users.noreply.github.com>
2026-08-12 14:07:45 -04:00
Nate Lentz 24ac89558c fix: hide hierarchy virtual path fields from the admin panel (#17763)
## What?

Hides the hierarchy virtual path fields (`_h_slugPath` and
`_h_titlePath`) from the admin panel again. `admin.hidden` was commented
out on both field definitions, so they rendered as read-only text inputs
in the edit view of every hierarchy-enabled collection.

## Why?

These fields are computed in the `afterRead` hook and are not editable.
Showing them adds two empty read-only inputs to the edit view with no
value to the user.

## How?

- Restore `admin.hidden: true` on both generated fields in
`addHierarchyToCollection`.
- Add a regression test in `test/hierarchy/int.spec.ts` that asserts
both fields are hidden on the sanitized collection config.
2026-08-12 15:37:32 +00:00
Jarrod Flesch 6c4df77021 fix: prevent multipart content-type backtracking (#17682)
Updates multipart `Content-Type` validation so parameter separators
cannot also be consumed as parameter content. This allows malformed
values to be rejected promptly while preserving the multipart subtype
and parameter formats Payload already supports.

---------

Co-authored-by: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com>
2026-08-11 13:43:29 -07:00
Paul aa0152043c test: fix sanitize unit test (#17752)
Fixes our CI issues with unit tests
2026-08-11 18:07:33 +00:00
Dan Ribbens 7bac6ab82a feat: support PAYLOAD_SECRET rotation with a versioned keyring (#17674)
## What

Adds a supported way to rotate `PAYLOAD_SECRET` — read data encrypted
under a previous secret and re-key it under the new one — without
silently corrupting data or breaking authentication.

Today the secret is used for reversible encryption (API keys), the
API-key HMAC lookup index, and JWT signing. There was no supported
rotation path, and the "obvious" hand-rolled migration silently corrupts
every API key because of the `apiKey` field's own encrypt/decrypt hooks.

## Changes

**Primitives**
- `payload.encrypt` / `payload.decrypt` accept an optional `{ secret }`
override (the raw `PAYLOAD_SECRET`, derived internally).
- New `payload.reencrypt(value, { oldSecret })` — decrypts with a
previous secret and re-encrypts with the active one.

**`rotateSecret` utility** (exported from `payload`)
- Re-keys the built-in `apiKey` **and** the coupled `apiKeyIndex` for
every auth collection using `useAPIKey`.
- Runs at the database-adapter layer to bypass the field hooks that
would otherwise corrupt data mid-rotation.
- **Idempotent** (re-run skips already-migrated rows via the HMAC index)
and **fail-closed** (a row matching neither the old nor the current
secret aborts before it is written).
- `dryRun` verifies every row without writing.

**Versioned, authenticated envelope**
- New writes use `v1:<keyId>:<iv>:<authTag>:<ciphertext>` (AES-256-GCM),
with an HKDF-derived 32-byte key and a non-secret `keyId` fingerprint.
- Legacy AES-256-CTR values are still read transparently and upgraded to
v1 on re-encrypt.
- A wrong-key / tampered / unknown-key value now fails loud (throws)
instead of returning garbage.

**Keyring for zero-downtime rotation**
- New `previousSecrets: string[]` config builds a keyring accepted for
**reads** — JWT verification and API-key lookup try each secret — while
writes always use the active `secret`.
- Enables rotation with no forced logout: add the old secret to
`previousSecrets`, deploy, run `rotateSecret`, then remove it.
- `apiKey` `afterRead` masks an undecryptable value (returns `null`)
instead of failing the whole document read.

## Notes

- **Password logins are unaffected** by a rotation — passwords use
pbkdf2 with a random per-user salt and never involve `PAYLOAD_SECRET`.
- Backwards compatible: the `encrypt`/`decrypt` additions are optional;
`previousSecrets` defaults to none; existing CTR data keeps reading.
- The v1 envelope is a forward format change — data written by a
v1-capable version is not readable by older Payload versions.

## Docs

New page: `docs/authentication/rotating-secret` (zero-downtime
procedure, `previousSecrets`, `rotateSecret` reference, envelope
format).

## Tests

Adds integration coverage under `test/auth` (isolated collections):
re-key happy path, idempotent re-run, wrong-secret abort, mid-run abort
+ resume, dry run, null-`apiKeyIndex` skip, legacy CTR → v1 upgrade,
masked read of an undecryptable value, password login unaffected,
API-key auth via a previous secret, JWT verification under a previous
secret (and rejection of an unknown one), plus v1 round-trip / legacy
back-compat / GCM tamper / unknown-keyId envelope tests. **97/97 pass on
MongoDB and Postgres.**

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-11 13:53:41 -04:00
German Jablonski f48de23691 feat!: replace collections dashboard widget with recently viewed widget (#17181)
## Summary

Removes the built-in `collections` dashboard widget (the
`CollectionCards` component) entirely and injects the `activity`
(recently viewed documents) widget into the stock `defaultLayout` at
`small` (33%) width in its place.

No recently viewed documents

<img width="1685" height="964" alt="image"
src="https://github.com/user-attachments/assets/321625d6-de53-42a0-a619-33c7bdb43623"
/>

With recently viewed documents

<img width="1680" height="968" alt="image"
src="https://github.com/user-attachments/assets/37d6513d-90c6-41da-a8dc-474c637725e5"
/>


## Why

The `collections` widget rendered a grid of collection and global cards
on the dashboard. In practice it did not add much value: it just
repeated information that is already available in the navigation
sidebar. It mostly existed as filler so the dashboard would not look
empty on a fresh install.

Now that we ship the `activity` (recently viewed documents) widget, we
have a better default that gives users something genuinely useful on the
dashboard, so the collections widget is no longer worth maintaining.

## Breaking change

This is a breaking change. Anything relying on the removed widget will
fail:

- The `CollectionCards` export was removed from `@payloadcms/ui/rsc`, so
any import or string component path
(`@payloadcms/ui/rsc#CollectionCards`, or the pre-v4
`@payloadcms/next/rsc#CollectionCards`) no longer resolves.
- `defaultLayout` entries with `widgetSlug: 'collections'` no longer
match a registered widget and are dropped from the rendered dashboard.
- Saved user dashboard preferences that contain the `collections` widget
silently drop it on next render; the rest of the layout is unaffected.

The navigation sidebar still provides access to every collection and
global, so no navigation capability is lost. This is documented in the
v4 migration guide.

## What changed

- **Core**: removed the `collections` widget registration in
`sanitize.ts` and pointed the stock `defaultLayout` at `{ widgetSlug:
'activity', width: 'small' }`.
- **UI**: deleted the `CollectionCards` widget (`index.tsx` +
`index.css`) and its `@payloadcms/ui/rsc` export.
- **Templates**: regenerated import maps no longer import the removed
`CollectionCards` symbol.
- **Docs**: added a dedicated breaking-change section to
`docs/migration-guide/v4.mdx` and updated
`docs/custom-components/dashboard.mdx` (default layout example +
built-in widgets).
- **Tests**: reviewed every e2e/int suite that depended on the widget or
its card DOM:
- `test/dashboard`: dropped the `collections` widget from the layout and
renumbered widget-position assertions; the type-level test and generated
types no longer reference `CollectionsWidget`.
- `test/admin`: removed the dashboard-card navigation and hidden/`group:
false` card tests (all already covered by the equivalent nav tests) and
rewired the history-replacement test to navigate through the nav
sidebar.
- `test/access-control`: removed the card-list visibility and
quick-create card tests (covered by the nav and list-view tests).
- `test/locked-documents`: removed the `dashboard - globals` block that
asserted lock indicators on the dashboard cards.
- `test/base-path`: the "navigate by clicking nav link" test now
actually clicks the nav link instead of a dashboard card.

## Test plan

- [ ] `pnpm test:int dashboard`
- [ ] `pnpm test:e2e dashboard`
- [ ] `pnpm test:e2e admin`
- [ ] `pnpm test:e2e access-control`
- [ ] `pnpm test:e2e locked-documents`
- [ ] `pnpm test:e2e base-path`
- [ ] Verify a fresh dashboard renders the recently viewed widget as the
default

---------

Co-authored-by: German Jablonski <GermanJablo@users.noreply.github.com>
2026-08-11 10:11:01 -04:00
Jessica Rynkar 82bccd1d37 chore(skills): clarify no virtual fields for useAsTitle (#17746)
Clarifies the Payload documentation and agent skill guidance for using
virtual fields with `admin.useAsTitle`.

Computed fields configured with `virtual: true` cannot be used as
`useAsTitle` because they are not queryable. Doing so causes Payload to
throw an `InvalidConfiguration` error during startup.

Relationship-path virtual fields, such as `virtual: 'author.name'`,
remain supported because they can be queried and sorted.
2026-08-11 14:36:22 +01:00
Patrik Kozak a5a4f78a18 Merge branch 'main' of github.com:payloadcms/payload into feat/authorship 2026-08-10 18:20:24 -04:00
Patrik Kozak eed0152441 Merge branch 'main' of github.com:payloadcms/payload into feat/authorship 2026-08-10 16:03:31 -04:00
Alessio Gravili 4fe5a1ed5f perf!: remove DeepRequired from sanitized global config types (#17742)
This PR removes `DeepRequired` from `SanitizedGlobalConfig`. It uses
shallow `Pick`, `Omit`, and `Required` types so only properties
populated during global config sanitization are required.

## Why

`DeepRequired` recursively processes large field, hook, admin, and
conditional types, adding unnecessary TypeScript checker work.

It was also inaccurate. `access.readVersions`, `admin.components`,
`graphQL`, `lockDocuments`, and `typescript` could be undefined but were
typed as required. Meanwhile, `_sanitized`, `admin`, `custom`, `label`,
standard access functions, and hook arrays are always present.

Global sanitization now also defaults `hooks.beforeOperation` to an
empty array, matching collection sanitization and the required sanitized
hook shape.

With the final `DeepRequired` usage removed from sanitized config types,
this also removes the `DeepClone<Metadata>` workaround and its paired
`formatMetadata` assertion, which were only needed to support it.

## Breaking change

Code using `SanitizedGlobalConfig` may now need to handle `undefined`
for properties Payload does not default.

```ts
if (global.access.readVersions) await global.access.readVersions(args)
const views = global.admin.components?.views
const interfaceName = global.typescript?.interface
```
2026-08-10 19:51:33 +00:00
Alessio Gravili dc3e6664f8 perf!: remove DeepRequired from sanitized config types (#17665)
Follow up of https://github.com/payloadcms/payload/pull/17541

This PR removes `DeepRequired` from `SanitizedConfig`. It uses shallow
`Pick`, `Omit`, and `Required` types so only properties populated during
root config sanitization are required.

## Why

`DeepRequired` recursively processes large admin, field, hook, GraphQL,
and plugin types, adding unnecessary TypeScript checker work.

It was also inaccurate. `admin.autoLogin`, `admin.livePreview`,
`graphQL.mutations`, `graphQL.queries`, `queryPresets`, and
`typescript.postProcess` could be undefined but were typed as required.
Meanwhile, `blocks` is always populated, `auth.jwtOrder` is supplied by
Payload despite previously being required in the incoming `Config`, and
`SanitizedConfig.paths` was typed but never existed at runtime.

The runtime defaults now also preserve the guarantees made by
`SanitizedConfig`: explicitly passing `undefined` cannot erase required
admin, GraphQL, route, TypeScript, or auth defaults, and localization
always receives `localeCodes` and `fallback` when enabled.

## Breaking change

Code using `SanitizedConfig` may now need to handle `undefined` for
properties Payload does not default. `Config.auth.jwtOrder` is now
optional for incoming configs, and the unused `SanitizedConfig.paths`
property has been removed.
2026-08-10 12:41:26 +01:00
Elliot DeNolf f0251ae769 feat: bundle payload skill into payload package (#17652)
Payload's agent skill now ships inside the `payload` npm package. The
guidance an AI assistant reads matches the version of Payload a project
has installed.

The skill previously reached users only through a Claude Code plugin
marketplace that tracks `main`. Guidance could describe APIs that a
project's installed version does not have.

Resolves PYLD-3360

`create-payload-app` no longer asks which assistant you use. It writes
two files at the project root:

```md
AGENTS.md

# AI Agent

For any Payload-related work, reference the skill at `node_modules/payload/skills/payload/SKILL.md`.
```

```md
CLAUDE.md

@AGENTS.md
```

Pass `--no-agent` to skip both.

## Key Changes

- **Skill ships with the package**
- The skill moved to `packages/payload/skills/payload/`. The package
`files` allowlist now includes `skills`.
- npm publishes the directory in the tarball. Consumers find it at
`node_modules/payload/skills/payload/`.
- **create-payload-app stops downloading the skill**
- A `buildAgentConfigFiles` builder replaces the tarball fetch from
GitHub. The generated files point into `node_modules`.
- The `try`/`catch` around the download is gone. A failed local write
now surfaces instead of degrading to a warning.
- **The agent prompt is removed**
- `select-agent.ts`, the `AgentType` union, and the `-a`/`--agent` flag
are deleted. All three former choices produced identical content.
- `--no-agent` is now the only opt-out. Runs passing `-a claude` will
fail on an unknown flag.
- **Marketplace entry retargeted**
- `source` is now `./packages/payload`. The deleted `plugin.json` is
inlined into the marketplace entry under `strict: false`.
- The plugin entry no longer sets `version`. That field pinned the
plugin and blocked updates.
- **Docs and evals follow the move**
- A new page, `docs/getting-started/ai-tooling.mdx`, covers both
delivery channels.
- The eval suite reads the skill from its new location. A spec now
guards that path resolution.

## Design Decisions

npm is the only channel that can version-match. Every marketplace
variant serves whatever sits on `main`.

Claude Code does not scan `node_modules`. A bundled skill stays
invisible until something points at it. Consumers therefore add a
pointer file. The cost is that the skill loses its frontmatter
advertisement, the roughly 100 tokens Claude Code normally loads into
the system prompt to make a skill self-triggering. An assistant will not
surface it unprompted.

The pointer names `SKILL.md` rather than the directory. `SKILL.md` opens
with a table linking to specific anchors under `reference/`, so it acts
as an index. Naming the directory instead leaves an agent to guess an
entry point among twelve files.

Claude Code reads `CLAUDE.md` and not `AGENTS.md`, so one file cannot
serve every assistant. `CLAUDE.md` imports `AGENTS.md` rather than
duplicating it, the interop pattern Claude Code documents. Editing
`AGENTS.md` updates both.

The marketplace plugin root is `packages/payload`. Claude Code's default
scan of `<root>/skills/*/SKILL.md` finds the skill without a `skills`
field. Inlining the manifest into the marketplace entry avoids
recreating a `plugin.json` inside a published npm package.

Dropping the plugin entry's `version` is deliberate. A version string
pins the plugin, and users receive updates only when that string
changes. A stale local cache holding an empty `skills/` directory is
what exposed this.

Both channels expose a plugin named `payload`. Enabling both puts two
copies at different versions in scope. The docs tell consumers to pick
one.

## Overall Flow

```mermaid
flowchart LR
    subgraph before ["Before"]
        A["GitHub main branch"] -->|"create-payload-app<br/>downloads tarball"| B["project/.claude/skills/payload"]
    end
    subgraph after ["After"]
        C["packages/payload/skills/payload"] -->|"npm publish"| D["node_modules/payload/skills/payload"]
        D -->|"AGENTS.md pointer,<br/>imported by CLAUDE.md"| E["AI assistant"]
    end
```

The skill used to arrive over the network at project creation, decoupled
from the installed version. It now arrives with the dependency itself.

## References / Links

- [Create and distribute a plugin
marketplace](https://code.claude.com/docs/en/plugin-marketplaces):
marketplace entry fields, source types, and the default skill scan.
- [How Claude remembers your
project](https://code.claude.com/docs/en/memory): confirms Claude Code
reads `CLAUDE.md` and not `AGENTS.md`, and documents the import pattern.
- [npm
package.json](https://docs.npmjs.com/cli/v11/configuring-npm/package-json):
the `files` allowlist that controls tarball contents.
2026-08-06 16:26:00 -04:00
Sasha a06593d030 fix(tanstack-start): hmr with payload config changes in dev (#17602)
Editing payload.config.ts, or anything it imports, had no effect until a
manual restart and logged nothing. Vite did re-evaluate the module, but
getPayload returns its cached instance unless a DevReloadStrategy marks
it stale, and endpoint routing reads payload.config off that stale
instance.

From there down this behaves exactly like Next.js: mark the instance
stale, then let the next getPayload call swap in the new config,
regenerate the import map and re-init the db. Only the signal and its
delivery differ.

Next.js gets that signal from defaultNextJsDevReloadStrategy, a
websocket to /_next/webpack-hmr watching for serverComponentChanges.
Nothing serves that path under Vite, so the connect failed and
ws.onerror swallowed it, which is where the silence came from. It is
also coarse: any server component change reloads Payload. The Vite
plugin instead uses the hotUpdate plugin hook, in-process and
socketless, and walks the changed module's importers so only edits that
actually reach the config trigger a reload.

Delivery differs because Next.js needs none. Its strategy is core's
built-in default, so it already covers the getPayload calls made inside
handleEndpoints and createPayloadRequest, which never see InitOptions.
An adapter has no such fallback, so core gains a globalThis-backed
strategy registry to give the plugin that same reach.

<!--

Thank you for the PR! Please go through the checklist below and make
sure you've completed all the steps.

Please review the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository if you haven't already.

## Which branch should this PR target?

- **`main`** — Payload v4 development. New features, enhancements, and
v4 bug fixes go here.
- **`3.x`** — Payload v3 maintenance. Bug fixes only. **New features are
not accepted against v3.** If this PR adds a feature to v3, please
retarget it at `main` (v4) instead.

The following items will ensure that your PR is handled as smoothly as
possible:

- PR Title must follow conventional commits format. For example, `feat:
my new feature`, `fix(plugin-seo): my fix`.
- Minimal description explained as if explained to someone not
immediately familiar with the code.
- Provide before/after screenshots or code diffs if applicable.
- Link any related issues/discussions from GitHub or Discord.
- Add review comments if necessary to explain to the reviewer the logic
behind a change

### What?

### Why?

### How?

Fixes #

-->


---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1216949983441202
2026-08-05 08:53:20 -04:00
Nate Lentz c7de1c4e17 chore: bump next.js from 16.2.7 to 16.3.0 (#17633)
Bumps the Next.js version used in the monorepo from `16.2.7` to
`16.3.0`, along with the pinned `@next/env` and
`@next/eslint-plugin-next` packages.

Stacked on #17632 — review that one first.

## TypeScript detection

Next.js 16.3 changes the default of `experimental.useTypeScriptCli` to
`true`. In CLI mode Next looks for a `typescript/bin/tsc` binary instead
of the compiler API at `typescript/lib/typescript.js`:

```js
const typescriptCliPackage = { file: 'typescript/bin/tsc', pkg: 'typescript', exportsRestrict: true }
```

This repo aliases `typescript` to `@typescript/typescript6`, which only
ships a `tsc6` bin. `getTypeScriptPackageInfo` reads
`packageJson.bin.tsc`, gets `undefined`, and Next reports the package as
missing — then tries to run `pnpm add --save-dev typescript` at the
workspace root, which fails:

```
It looks like you're trying to use TypeScript but do not have the required package(s) installed.
[ERR_PNPM_ADDING_TO_ROOT] Running this command will add the dependency to the workspace root...
```

Both `next.config.mjs` and `test/next.config.mjs` now set
`useTypeScriptCli: false` so Next keeps using the compiler API, which
the alias does provide.

## Scope

Templates stay on `16.2.7` and are bumped separately, to keep this diff
reviewable.

## Testing

- `@next/env` resolves at `16.3.0` from both `packages/payload` and
`test`; `@next/eslint-plugin-next` resolves at `16.3.0` from
`packages/next`.
2026-08-04 15:30:56 -07:00
Nate Lentz e710608ca9 fix: connect to the correct Next.js dev HMR endpoint per version (#17632)
### What?

Payload's config hot reload in development stopped working on Next.js
16.3. Adding a collection or a field had no effect until the dev server
was restarted.

Next.js 16.3 renamed the dev HMR WebSocket endpoint:

- Old: `/_next/webpack-hmr` — [`router-server.ts#L859-L860` on
`v16.2.7`](https://github.com/vercel/next.js/blob/v16.2.7/packages/next/src/server/lib/router-server.ts#L859-L860)
- New: `/_next/hmr` — [`router-server.ts#L946-L947` on
`v16.3.0`](https://github.com/vercel/next.js/blob/v16.3.0/packages/next/src/server/lib/router-server.ts#L946-L947)

The client moved with it, in
[`client/dev/hot-reloader/app/web-socket.ts`](https://github.com/vercel/next.js/blob/v16.3.0/packages/next/src/client/dev/hot-reloader/app/web-socket.ts).
There is no alias in either direction: `/_next/webpack-hmr` does not
appear anywhere in the 16.3.0 build, and `/_next/hmr` does not appear
anywhere in 16.2.7.

### Why?

Payload hardcoded `/_next/webpack-hmr`. On 16.3 the upgrade request no
longer matched, so the socket never opened. The failure was invisible
because the `onerror` handler and the surrounding `connect` call both
swallow errors, so `onReload` never fired, `cached.reload` stayed
`false`, and `reload()` never ran. No type regeneration, no import map
regeneration, and no client config cache clear. Just an incorrect
endpoint.

[Related Vercel PR ](https://github.com/vercel/next.js/pull/91415)which
went into canary several months ago

### How?

Read the installed Next.js version and connect to the path that version
serves: `/_next/hmr` at 16.3 and above, `/_next/webpack-hmr` below it.
One socket, no configuration, correct across supported Next.js versions.

Version detection reads `next/package.json` resolved from
`process.cwd()`, reusing the existing `resolveFrom` and
`compareVersions` helpers. `resolveFrom` goes through
`Module._resolveFilename`, so Next's `exports` map cannot block it.
Since resolution starts at the app root, an app on 16.3 is detected as
16.3 regardless of what this repo pins for its own tests.

Two deliberate calls in that decision:

- Pre-release identifiers are ignored, so `16.3.0-canary.12` maps to
`/_next/hmr`. Strict semver would rank it below `16.3.0` and pick the
legacy path.
- An unreadable version uses `/_next/hmr`. Payload being unable to
resolve Next.js usually means it is not running under Next.js, where
this strategy does not apply.

Trying the new path first and falling back when it fails is **not**
viable, which is worth recording for future reference. Next.js answers
only on the path its own version serves, and leaves any other upgrade
request open and unanswered so that a custom WebSocket server can claim
it. Probing a real 16.2.7 dev server:

```
/_next/hmr             => no response after 8s
/_next/webpack-hmr     => open
/_next/not-a-real-path => no response after 8s
```

A wrong path never errors and never closes, so a fallback triggered by
failure would hang forever on Next 16.2 and below and break HMR for
every older version.

`PAYLOAD_HMR_URL_OVERRIDE` keeps working and is still used verbatim,
skipping version detection.

The strategy also moved out of `packages/payload/src/index.ts` into
`packages/payload/src/utilities/nextJsDevReloadStrategy.ts` so it can be
tested directly.

### Testing

Unit tests cover path selection per version, pre-release handling, the
unknown-version default, message handling, cleanup, and the override.
Written first and confirmed failing against the old behaviour before the
fix.

Verified end-to-end against a running dev server on 16.2.7 to confirm no
regression on the older endpoint: editing a collection regenerated the
import map and the new field appeared in the admin UI without a restart.
The 16.3 path is covered by the endpoint rename in Next's source plus
unit tests, not by a live reload — booting this repo's dev server on
16.3.0 is blocked by an unrelated problem in Next 16.3's rewritten
TypeScript verification, which reports `typescript` as missing and tries
to install it at the workspace root. That is a separate piece of work,
and this fix is version-independent, so the repo's Next pin stays at
16.2.7 here.
2026-08-04 14:10:18 -07:00
Jarrod Flesch 2cdaa1f017 refactor!: remove deprecated auth result strategy (#17597)
Removes the deprecated top-level `strategy` property from `/me` and
`/refresh-token` results across REST, GraphQL, SDK types, refresh hooks,
and the UI auth context. The supported `user._strategy` replacement
remains available and is covered by real REST and GraphQL response
tests.

## Breaking Changes

Consumers reading the top-level `strategy` property from `/me`,
`/refresh-token`, their GraphQL equivalents, or the SDK `MeResult` and
`RefreshResult` types must migrate to the authenticated user's
`_strategy` property.

Old:

```ts
result.strategy
```

New:

```ts
result.user._strategy
```

---------

Co-authored-by: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com>
2026-08-04 21:07:50 +00:00
Jarrod Flesch b5b6a2b403 feat(ui)!: refresh auth sessions from user activity (#17481)
Refreshes authenticated admin sessions from recent mouse, keyboard,
focus, and route activity without sending a request for every event.
Activity within the tracking window is remembered until the refresh
checkpoint, while the final reminder window still requires the user to
explicitly remain signed in.

## Breaking Changes

Consumers reading `tokenExpirationMs` from `useAuth()` must use
`authSession.expiresAt` instead.

```diff
- const { tokenExpirationMs } = useAuth()
+ const { authSession } = useAuth()
+ const tokenExpirationMs = authSession?.expiresAt
```

## Before and after

Before:

Admin route changes invoked the refresh scheduler. For a normal-length
token, a route change only sent a refresh request once the token had
less than two minutes remaining. Mouse movement, keyboard input, and
window focus did not count as session activity.

After:

Mouse movement, keyboard input, window focus, and pathname or
search-parameter changes count as activity while the tracking window is
open. Activity recorded before the refresh window is remembered until
the refresh checkpoint; activity inside the refresh window triggers a
refresh. Once the final reminder window opens, background activity no
longer refreshes the token and the user must explicitly choose to remain
signed in.

## Token windows

For a token issued with a 5 minute lifespan (5 is arbitrary for example
sake):

- **5:00–4:00 remaining · Waiting** — Activity does not queue a refresh
yet.
- **4:00–2:00 remaining · Tracking** — Qualifying activity is recorded.
If activity occurs, the token refreshes when the refresh window opens
with two minutes remaining.
- **2:00–1:00 remaining · Refresh window** — Qualifying activity
triggers a token refresh.
- **1:00–0:00 remaining · Reminder** — Automatic activity refresh is
closed. The user must explicitly choose to remain signed in.
- **0:00 remaining · Expired** — The session logs out if it was not
refreshed.

A successful refresh starts the lifecycle again using the new token
expiration. Without qualifying activity during the tracking or refresh
windows, the token is not refreshed in the background. For shorter
tokens, any window that would begin before issuance opens immediately.
The reminder window shrinks to half the token lifespan for tokens under
two minutes.

### Debugger helper component

Exposes the typed `authSession` lifecycle through `useAuth()` and
exports `AuthSessionDebug` for inspecting expiration, refresh, and
activity timing in development:

```ts
admin: {
  components: {
    providers: ['@payloadcms/ui#AuthSessionDebug'],
  },
}
```

Coordinates refresh and logout across tabs through both
`BroadcastChannel` and the storage fallback. Stale request results are
discarded so a delayed refresh cannot restore a session after explicit
logout or provider-side revocation.

Adds a real auth-session test suite with opaque HTTP-only tokens,
server-side rotation and revocation, and context-wide virtual time. Its
Playwright coverage exercises activity refresh, provider revocation,
cross-tab synchronization, inactivity expiration, and logout races
without request interception.

### Video reference

The video shows how the auth session debugger works. I paused the video
between windows so it was not 5m long.


https://github.com/user-attachments/assets/6c4f1168-e4e8-4cd9-9b64-9e58178a2f79
2026-08-03 16:47:13 -04:00
Alessio Gravili 4736d2514f perf!: remove DeepRequired from sanitized collection types (#17541)
This PR removes `DeepRequired` from `SanitizedCollectionConfig` and its
auth type. It uses shallow `Pick`, `Omit`, and `Required` types so only
properties populated during sanitization are required.

## Why

`DeepRequired` recursively processes large field, hook, admin, and
conditional types, adding unnecessary TypeScript checker work.

It was also inaccurate. `access.readVersions`, `auth.cookies.domain`,
`auth.useAPIKey`, and `auth.depth` could be undefined but were typed as
required. Meanwhile, `auth.forgotPassword`, `auth.verify`, and
normalized username-login options were typed as optional despite always
being populated.

## Breaking change

Code using `SanitizedCollectionConfig` may now need to handle
`undefined` for properties Payload does not default.

```ts
const domain = collection.auth.cookies.domain
if (domain) useDomain(domain)
if (collection.access.readVersions) await collection.access.readVersions(args)
const useAPIKey = collection.auth.useAPIKey ?? false
```
2026-07-31 15:08:27 -04:00
Alessio Gravili 555045d132 perf!: 37% faster config sanitization by making it synchronous (#17580)
## What?

This PR makes config sanitization synchronous:

- `sanitizeConfig`, `sanitizeField`, and `sanitizeFields` now return
their result directly instead of a promise.
- Collections and globals are sanitized synchronously.
- Lexical editor config and server features are sanitized synchronously.
- Rich-text fields are sanitized on the spot, instead of queueing up
promises to await at the end.

## Why?

Sanitizing the config is pure CPU work - it never touches a database,
the filesystem, or the network. So marking it `async` gave us nothing in
return, and it made the code harder to follow: every caller had to
`await`, and rich-text fields needed a list of pending promises just to
finish the job later. Returning values directly removes all of that, and
the lower-level sanitizer and editor-config APIs become easier to use
because you get a result immediately.

Being synchronous also sends a clearer signal to anyone writing a
rich-text adapter or a Lexical server feature: setting up a feature
should be cheap. When these callbacks are allowed to return promises, it
invites slow I/O into code that runs on every cold start.

Dropping the promise overhead also speeds up cold starts.

## Performance

Lambda cold-start measurements used a realistic 20-collection project
with deeply nested fields and complex Lexical content.

Cold starts were averaged within each deployment, and the final average
gives all three deployments equal weight.

| Deployment | Main (`4.0.0-canary.19`) | This PR
(`4.0.0-internal.d1cc1d3`) | Change |
| ---------- | --------------------------------: |
--------------------------------------: | -----: |
| 1          | 621.85 ms | 411.63 ms | -33.8% |
| 2          | 859.27 ms | 424.16 ms | -50.6% |
| 3          | 2,248.65 ms | 1,501.03 ms | -33.2% |
| **Average** | **1,243.25 ms** | **778.94 ms** | **-37.3%** |

Deployment 1 and 2 used the same config. Deployment 3 had additional
lexical fields and blocks added. We also measured the change in a
synthetic config-sanitization benchmark. The benchmark uses 925
collections and 15,153 field definitions:

|      | Before | After | Change |
| ---- | -----: | ----: | -----: |
| Mean | 75.29 ms | 69.42 ms | -7.8% |

The synthetic benchmark scales up the fields test suite by duplicating
its fields and was run on an M4 Max. The deployment benchmark instead
uses a realistic large config on a slower Lambda CPU, where the
improvement was more noticeable.

## Breaking change

Most projects are unaffected. You still use `buildConfig(...)` and
`lexicalEditor(...)` exactly as before, and `buildConfig` itself is
still `async`.

You are affected if you:

- Call the lower-level sanitizer APIs yourself.
- Ship a custom rich-text adapter that returns a promise.
- Ship a Lexical server feature callback that returns a promise.

If you call a sanitizer directly, drop the `await`:

```diff
- const sanitizedConfig = await sanitizeConfig(config)
+ const sanitizedConfig = sanitizeConfig(config)
```

Custom rich-text adapters and Lexical server feature callbacks now have
to return their value synchronously. If you were doing slow work or I/O
there, move it either into an async plugin that runs before
sanitization, or into a runtime hook.

<sub>Stack created with <a
href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a
href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>
2026-07-31 17:42:03 +00:00
Alessio Gravili 21f70856a8 fix: preserve parent localization through tabs (#17578)
This fixes two related localization bugs in `traverseFields`.

First, entering a non-localized tab always reset `parentIsLocalized` to
`false`. Fields after the tab no longer knew that an earlier parent was
localized. During Copy to Locale, this could reuse nested array or block
row IDs and cause a database unique-constraint error.

Second, traversal checked `tab.localized` directly. A tab with
`localized: true` inside an already-localized parent was incorrectly
treated as another locale layer. This could make traversal interpret
field names as locale names and skip nested fields.

Tabs now preserve inherited localization and only create a new locale
layer when `fieldShouldBeLocalized` says they should. This matches
localization handling behavior we have in other parts of Payload, like
our hooks.
2026-07-31 08:34:00 -07:00
Patrik 6d26f905d7 fix: respect locale when counting global versions (#17549)
Passes `req.locale` to `payload.db.countGlobalVersions` in the
`countGlobalVersions` operation so queries on localized fields resolve
against the correct locale.

Previously the operation omitted `locale`, so a `where` clause targeting
a localized field (e.g. `version.text`) was matched against the wrong
locale's data, returning incorrect counts.

Before:

```ts
const result = await payload.db.countGlobalVersions({
  global: global.slug,
  req,
  where: fullWhere,
})
```

After:

```ts
const result = await payload.db.countGlobalVersions({
  global: global.slug,
  locale: req?.locale || undefined,
  req,
  where: fullWhere,
})
```
2026-07-30 08:32:24 -07:00
Sasha 257d681b4b fix: replace image-size with image-dimensions dependency for upload dimensions (#17077)
## What

Removes the direct runtime dependency on the archived `image-size`
package.
Upload dimensions now come from `sharp` when it's configured, then from
`image-dimensions` for the formats it supports, with a small set of
hand-written parsers as the last-resort fallback.

## Why

`image-size` is archived and stuck at `2.0.2` with unpatched DoS
advisories
(CVE-2025-71330, CVE-2025-71319) reachable via crafted uploads. No
upstream fix
is coming. `sharp` stays optional.

The first version of this PR replaced `image-size` with a single
hand-rolled
probe covering every format. On review, maintaining a full custom
image-format
parser indefinitely was flagged as more burden than it's worth, so this
now
delegates to
[`image-dimensions`](https://github.com/sindresorhus/image-dimensions)
(zero dependencies, actively maintained, MIT) for PNG, JPEG, GIF, WebP,
AVIF
and HEIC/HEIF — including the ISO-BMFF box-walking logic where both CVEs
originated. Its box reader already rejects a zero-size box,
independently
closing the hole those CVEs exploited. Only BMP, ICO/CUR, SVG and TIFF
have no
maintained equivalent, so those stay hand-written here.

## How

- `getImageSize` prefers `sharp.metadata()` when `sharp` is configured.
- `probeImageSize` tries `image-dimensions` first, then falls back to
bounded
parsers for BMP, ICO/CUR, SVG and TIFF — each advances by a fixed amount
or
  is bounded by an explicit guard, so malformed input can't loop.
- Drops the TIFF temp-file workaround (parsed from buffer now).
- Removes `image-size` from `package.json` / the lockfile; adds
  `image-dimensions`.

---------

Co-authored-by: Paul Popus <paul@payloadcms.com>
2026-07-30 15:27:36 +01:00
Jake Fletcher b292343a89 feat: framework agnostic auth server functions (#17525)
Moves `login`, `logout`, and `refresh` server functions into `payload`
core behind `payload/auth`, where they share the same auth operations
and cookie utilities as the rest of Payload.

The Next.js and TanStack Start adapters are now thin wrappers around one
implementation instead of maintaining separate copies. This standardizes
cookie and request handling across frameworks and prevents the adapters
from drifting independently.

Projects relying on the previous adapter-specific cookie behavior may be
affected by the following fixes:

- `cookies.sameSite` boolean values now match Payload core: `true` emits
`Strict` and `false` omits the attribute. The framework adapters
previously emitted `Lax` for either value. The default `'Lax'` behavior
and explicit string values are unchanged.
- `logout` expires cookies using the same `domain`, `path`, and `secure`
attributes they were created with, allowing scoped cookies to clear
correctly.
- `login` failing to set the auth cookie when `removeTokenFromResponses`
is enabled. Previously, the token was removed before the cookie could be
written.

Other related changes:
- Tests: the `test/server-functions` suite has been merged into the
`test/auth` suite for better visibility. It's also been reworked into a
cross-framework setup.
- Docs: moves the server function docs from the Local API section into
Authentication and documents their TanStack Start equivalents.

TL;DR:
- Standardizes all auth server functions into payload core
- The Next.js and TanStack adapters are now thin wrappers around these
core utils
- Fixes a few found issues within the server functions
- The `test/server-functions` suite has been absorbed into `test/auth`
2026-07-29 17:19:03 -04:00
German Jablonski b7a76c13b1 fix!: apply the documented auth.depth default of 0 (#17540)
`auth.depth` has been documented as defaulting to `0` since it was
introduced, but that default was never applied.
`addDefaultsToAuthConfig` has a line for every other auth default except
`depth`, so it stayed `undefined`, the auth strategies passed that
straight into `findByID`, and `afterRead` fell back to `defaultDepth`.
The result is that `req.user` has been populated on every authenticated
request rather than not at all: two levels deep before #17510, one level
after.

The fix is the one missing line, `auth.depth = auth.depth ?? 0`.

Labelled breaking because relationship and upload fields on `req.user`
become IDs, in access control, hooks and `/api/users/me`. Access control
that reads something like `user.tenant.owner.id` now gets an ID and
denies quietly, so the failure is silent rather than an error. Anyone
who wants the old behaviour can set `auth.depth` on the collection,
which is what the prop is for.

Added a test asserting the sanitized value on an auth collection that
omits `depth`; without the fix it fails with `expected undefined to be
+0`. The full int matrix, unit and type tests are green. I also checked
the rest of the repo for code that would need updating alongside this:
`test/auth/config.ts` is the only place that sets `auth.depth`, no
template or example has a relationship or upload field on an auth
collection (their `roles` fields are all `select`s, which depth does not
touch), and the multi-tenant and ecommerce plugins already normalize
both the ID and the document shape.

Co-authored-by: German Jablonski <GermanJablo@users.noreply.github.com>
2026-07-29 12:11:53 -04:00
Jake Fletcher 76a95043ea perf!: reduce default query depth from 2 to 1 (#17510)
Reduces Payload's default query depth from `2` to `1`, improving query
performance out of the box for every application without requiring
configuration changes. Queries now avoid second-level relationship
population—and its associated database, transformation, and
serialization work—unless explicitly requested.

Although a default of `0` would produce the fastest possible queries,
`depth: 1` strikes a better balance between performance and DX. `2` is
unnecessarily expensive as a default, while the tradeoff between `0` and
`1` is less clear. Ultimately, `1` gives us the best of both worlds:
substantially less relationship population by default without making the
default response shape too limited.

Related discussion:
https://github.com/payloadcms/payload/discussions/15351

## Breaking Changes

Applications that rely on implicit second-level relationship population
are affected. Queries that omit `depth` now populate one relationship
level instead of two.

Request the additional depth where needed:

```diff
const posts = await payload.find({
  collection: 'posts',
+ depth: 2,
})
```

To preserve the previous behavior application-wide:

```diff
export default buildConfig({
+ defaultDepth: 2,
})
```

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1216910993520508
2026-07-29 10:01:11 -04:00
vansh 34e5b464f7 fix: pass id to delete access control on soft delete in updateByID (#17453)
### What?
Fixes a missing `id` in the `access.delete` control callback when a
document is soft-deleted (trashed) via the `updateByID` operation.

### Why?
When a collection has `trash: true`, soft-deleting a document goes
through the `updateByID` operation. The `access.update` callback
correctly receives `{ id, data, req }`, but the `access.delete` callback
(called right below it for the trash check) only received `{ data, req
}` — `id` was missing. This makes it impossible for a `delete` access
control function to reliably identify which document is being trashed,
forcing developers to rely on `data.deletedAt` alone, which is poor DX
and was already flagged as confusing in the linked issue.

### How?
One-line fix in
`packages/payload/src/collections/operations/updateByID.ts` — `id` was
already destructured and in scope at that point in the function, so it
just needed to be passed through:

```diff
- const deleteAccessResult = await executeAccess({ data, req }, collectionConfig.access.delete)
+ const deleteAccessResult = await executeAccess({ id, data, req }, collectionConfig.access.delete)
```

Fixes #17452
2026-07-28 15:40:37 -04:00
Alessio Gravili 9dd83ab1fb feat!: turn job concurrency and parent logging always on (#17505)
This follow-up PR finishes the remaining v4 jobs queue breaking changes
on top of `feat/job-queue-deprecations`.

Concurrency controls and parent task logging are now always enabled.

## Default job behavior

The jobs collection always includes the indexed `concurrencyKey` field
and concurrency rules are always enforced when a task or workflow
configures them.

Nested task logs always include their parent task information to allow
for observability. The `jobs.enableConcurrencyControl` and
`jobs.addParentToTaskLog` configuration properties have been removed.

## Type cleanup

`TaskType` has been renamed to `TaskSlug`, and `WorkflowTypes` has been
renamed to `WorkflowSlug`.

The deprecated `RunningJob` and `RunningJobSimple` types have been
removed. `Job` is now the supported job document type.

Task log input is required and falls back to an empty object when a task
has no input.

## Migration notes

Existing projects that use jobs and sql should generate a database
migration. The default jobs schema now includes the indexed
`concurrencyKey` field and parent task log fields, and task log input is
required.

The removed configuration properties should be deleted from existing
Payload configs.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1216905504637355
2026-07-28 19:08:48 +00:00
Paul 1ee8407c2e fix!: type DefaultValue supporting promises to reflect being awaited internally (#17522)
Updates the type of `DefaultValue` to also accept a Promise, reflects
the actual code being awaited.

## Breaking changes
You now need to await the defaultValue functions to cover for the return
value potentially being a promise:

```diff
import type { DefaultValue } from 'payload'

- const resolveDefault = (fn: DefaultValue, args: Args) => {
-   const value = fn(args)
-   return value.toString()
- }
+ const resolveDefault = async (fn: DefaultValue, args: Args) => {
+   const value = await fn(args)
+   return value.toString()
+ }
```
2026-07-28 19:02:32 +01:00
Paul 8c1769f039 feat: support async filterOptions on select fields (#17521)
## Summary

`filterOptions` on `relationship`, `upload`, and `blocks` fields has
always supported returning a `Promise`, and it's correctly `await`ed
everywhere it's called. `select` fields (and
`queryPresets.filterConstraints`, which is aliased to the same type)
were the odd one out — the type only allowed a sync `Option[]` return,
and the one place it's invoked (`validations.ts`) never awaited it. If
you defined an async `filterOptions` on a `select` field anyway (nothing
stopped you at runtime), the validator would call `.some()` on a
`Promise` object and throw `TypeError: filteredOptions.some is not a
function`, and the admin UI would assign a raw `Promise` to
`selectFilterOptions` instead of the resolved array.

This PR brings `select.filterOptions` to parity with the other fields so
`await` is the uniform calling convention everywhere.
2026-07-28 17:59:38 +01:00
Patrik Kozak 02e3762940 Merge branch 'main' of github.com:payloadcms/payload into feat/authorship 2026-07-28 11:18:08 -04:00
Sasha 779e387458 fix: restore version fails validation on relationship filterOptions fields (#17446)
Restoring a collection version through the REST API could report valid
relationship selections as invalid when the field defines
`filterOptions`.

1. Version restore validates data using the default locale, requiring an
isolated `locale` and `fallbackLocale`.
2. The derived request inherited native `Request` getters backed by
private fields.
3. Accessing properties such as `headers`, `url`, or `method` used the
derived object as the receiver and threw. Relationship validation caught
the error and reported the selection as invalid.

The fix is to isolate only `locale` and `fallbackLocale` with
`isolateObjectProperty`, preserving access to the original request’s
native properties.

Adds a REST regression test covering version restore with a
`filterOptions` relationship field.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1216597988811194

---------

Co-authored-by: Jake Fletcher <jacobsfletch@gmail.com>
2026-07-28 10:20:45 -04:00
Patrik Kozak 0721c6f86b fix: prevent client-side authorship spoofing and re-attribute on duplicate 2026-07-27 15:21:21 -04:00
Patrik Kozak e65de7a555 chore: simplify comments 2026-07-27 14:15:40 -04:00
Patrik Kozak 3adde613da Merge branch 'main' of github.com:payloadcms/payload into feat/authorship 2026-07-27 13:03:37 -04:00
Alessio Gravili 51fd3bee91 feat!: replace BaseJob with a single Job type, enable job stats and meta field (#17504)
This PR removes the deprecated `BaseJob` type and makes `Job` the single
type for queued jobs.

`Job` now uses the generated `payload-jobs` collection type when it is
available. When generated job types are not available, it uses a
fallback defined in `UntypedPayloadTypes`.

## What changed

### One job type

The public `BaseJob` type has been removed. `Job` now automatically uses
the generated `payload-jobs` collection type when it exists and the
default job document from `UntypedPayloadTypes['collections']` when it
does not.

### Simpler workflow types

Workflow type generics are now simpler. They now accept either a
workflow slug or an input object. They no longer use `false` as a
fallback value. This reduces the data types accepted by this generic
from 3 (false / object / slug string) to 2 (object / slug string).

### Default job metadata

The `payload-jobs-stats` global and the job `meta` field are now always
added whenever jobs are enabled. They are no longer opt-in. This
requires a database migration.

## Why

Previously, core code had to choose between `Job` and `BaseJob`
depending on whether generated types existed. This made job types harder
to understand and caused different parts of the codebase to use
different fallback logic.

Now there is one `Job` type with one automatic fallback. Core code can
use the same collection access pattern that users use:

```ts
TypedCollection['payload-jobs']
```

## Breaking changes

`BaseJob` is no longer exported and should be replaced with `Job`.

Job and workflow generics now use `object` as their default instead of
`false`.

Projects with jobs enabled will always include the `payload-jobs-stats`
global and the job `meta` field. Existing projects may need a database
migration for these managed fields.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1216905504637347
2026-07-27 16:28:18 +00:00
Alessio Gravili e60ebcd676 feat!: prevent stale jobs queue jobs (#17220)
This PR lets Payload recover Jobs Queue jobs after a worker crashes. It
replaces the permanent `processing` flag with a renewable
`processingUntil` lease and builds on
[#17441](https://github.com/payloadcms/payload/pull/17441), which makes
the initial job claim safe when multiple workers poll the same queue.

## Why

Previously, a worker set `processing: true` when it claimed a job. If
that process crashed, nothing reset the flag, so Payload treated the job
as running forever.

For example, a schedule runs every 10 minutes but does not queue a new
job while the previous one is processing. If its worker crashes, the old
job stays at `processing: true`, cannot be picked up again, and can
block every future scheduled run.

## How it works

A job is claimable when `processingUntil` is missing or expired.
Claiming it atomically writes:

- `processingUntil`: how long Payload should consider the worker active
- `processingToken`: which worker attempt currently owns the job

While the job runs, a heartbeat renews `processingUntil` every third of
the lease duration. A healthy job can therefore run longer than one
lease; this is not a task timeout.

Worker-owned updates - including heartbeats, task logs, completion, and
handled errors - must still match the worker's token and have enough
lease time remaining. If an update no longer matches, Payload throws an
internal `JobRunAbortedError` and stops accepting results from that
worker. This prevents a timed-out worker from overwriting a newer
attempt.

When a worker disappears, its heartbeat stops. After the lease expires,
the next normal `payload.jobs.run()` or `runByID()` call can claim the
job directly with a new token and lease. No separate cleanup step is
required.

A crash intentionally does not increment `totalTried` or consume the
handler's retry configuration. Completed task logs are kept, so recovery
continues from the remaining tasks.

## Configuration

The default lease is 20 minutes and the default safety buffer is 30
seconds:

```ts
jobs: {
  processingLease: {
    duration: 20 * 60 * 1000,
    safetyBuffer: 30 * 1000,
  },
}
```

`duration` controls how long a job remains owned without a successful
heartbeat, and will delay recovery. 20 minutes was chosen as the
default, because recovery usually is rare and not time-sensitive.

`safetyBuffer` is the minimum time that must remain before a worker
starts a job update. It gives db adapters that read and write separately
time to finish before the lease expires. It should be longer than a
normal database update and shorter than the lease duration.

## Why the token and safety buffer are both needed

[#17441](https://github.com/payloadcms/payload/pull/17441) protects
claim time: only one worker can change a pending job into a claimed job.

This PR must also protect later writes. After a lease expires, Worker B
can recover a job while Worker A is still finishing old work.
`processingUntil` alone only says that some worker has an active lease.
The stable `processingToken` identifies whether the update belongs to
Worker A or Worker B.

Some db adapters first find a matching job and then write it. Without a
safety window, this race is possible:

1. Worker A verifies its token just before its lease expires.
2. The lease expires before A's job update write finishes.
3. Worker B claims the job with a new token.
4. A's delayed write could overwrite B's state.

The safety buffer prevents A from starting that update near lease
expiry. This is a cross-db-adapter safety margin, not an atomic database
guarantee. It can be increased for slower remote adapters, if you expect
an update to take longer than 30 seconds.

MongoDB applies the ownership query and update atomically with
`findOneAndUpdate()` or `updateMany()`, so it does not rely on the
buffer for correctness. The buffer can be set to `0` for a db adapter
like mongodb with the same guarantee.

## Breaking change

The `processing` field on `payload-jobs` is replaced by the nullable,
indexed `processingUntil` date. Custom collection overrides and direct
database queries that use `processing` must be updated.

Existing SQL databases need a migration that replaces this column.
Migration files will be added in a separate PR; MongoDB does not require
a schema migration.

---

- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1216353649387086
2026-07-26 22:38:00 +02:00
Jake Fletcher 0776da590d feat: generate static slugs with unique fallback and optional source (#17458)
Makes the native `slug` field static: it's set once on create instead of
continuously tracking its source field. A `<singular>-<N>` fallback
guarantees the slug is present the moment the doc is created — including
on autosave drafts.

This is what live preview needs. The preview loads a document by its
slug on mount — but an autosave collection creates a draft on
navigation, before the user has typed a title. Without a slug at that
point, the preview has nothing to query and the page ultimately 404s.
The fallback gives that initial draft a stable, immediately discoverable
slug the page can query.

This also cuts iframe churn in dynamic live preview URLs. The preview
URL is built from the slug, so every slug change reloads the iframe
`src`. A static slug only changes when you explicitly generate it, so
creating an autosave document no longer thrashes the iframe.

The slug is only ever auto-filled while empty or when you explicitly
regenerate it:

- An empty slug is derived from its source field
- With no source, it falls back to `<singular>-<N>`, where `N` is the
first available integer, e.g. `posts-1`
- Every value is normalized through the field's `slugify`, e.g. `Hello
World` → `hello-world`

**This replaces the previous generate-while-you-type approach during
initial autosave doc creation.**

For context: one goal of #17215 was to remove the extra injected
checkbox field of the old `slugField()` util, which acted as the
auto-generation gate. Without that flag, inferring "follow the title
until the user edits the slug" has to be done statelessly — inherently
flaky, since the server can't reliably tell a genuine edit from an
override.

A static slug removes the guesswork without reintroducing that field:
it's computed once, never recomputed.

This works across localization. A localized slug is unique per locale —
you can reuse the same value across a document's own locales, but not
across documents within one locale. Every locale is populated on create,
so switching locales never lands on a blank slug, and generation is
locale-aware when the source is localized.

### Optional Source

**The `useAsSlug` field property is now optional.**

It previously threw `InvalidConfiguration` at sanitize time when
missing, because a slug had no way to populate itself without a source
field to derive from.

That constraint no longer holds. Every generation path now ends in a
guaranteed-unique fallback. So a slug field is fully functional with no
source: the user types an explicit value (slugified and
uniqueness-checked) or the field auto-increments a collision-free
fallback.

`useAsSlug` is now purely a convenience for deriving a human-readable
slug from a sibling field (e.g. `title`).

 ```ts
 {
  name: 'slug',
  type: 'slug',
  useAsSlug: 'title' // NOW OPTIONAL
}
 ```

### Final Result


https://github.com/user-attachments/assets/d313c4af-f9e3-49c1-91bb-3e43532f62c1
2026-07-24 15:00:30 -04:00
Alessio Gravili 7ae0cda45a feat!: support multiple workers on the same queue (#17441)
This PR prevents two workers on the same queue from claiming and running
the same job.

Closes https://github.com/payloadcms/payload/issues/16043 and
https://github.com/payloadcms/payload/discussions/17129

## The problem

Two workers can look for work at the same time and find the same
available job. The database adapter must ensure that only one worker
returns that job as successfully claimed.

## The solution

Each worker writes a unique `processingToken` when it claims jobs.
MongoDB and Drizzle use different database operations, so they use the
token differently.

### MongoDB: find, update, find

MongoDB uses three steps because `updateMany` does not return the
updated documents:

1. Find candidate jobs.
2. Update candidates that are still available, setting `processing:
true` and this worker's token.
3. Find candidates with this worker's token.

The final find now tells us which jobs **this worker's update** claimed.
`processing: true` could not identify them because every worker writes
the same value.

### PostgreSQL and SQLite: find, update and return

Drizzle first finds candidates, then runs one conditional `UPDATE ...
RETURNING` statement. The update only changes jobs that are still
`processing: false` and directly returns the IDs it changed.

If two workers found the same job, only one update can change it from
`processing: false` to `true`. The other update does not return that
job, so only the winner runs it.

For this specific race, Drizzle does not need the `processingToken` to
identify the winner because the update already returns the winning IDs.
The token is mainly needed by MongoDB's final find and is stored
consistently by every adapter.

`FOR UPDATE SKIP LOCKED` is not needed to prevent duplicate claims. It
could reduce waiting under heavy contention, but would add
PostgreSQL-specific code.

Payload clears `processingToken` when a job stops processing.

## What this does not fix

This PR does not add leases or crash recovery. A crashed worker can
still leave `processing: true` behind; the follow-up PR will address
that.

## How this helps crash recovery later

The token also gives Payload a clear way to identify which worker
currently owns a job. In the follow-up crash-recovery PR, a replacement
worker can claim an abandoned job with a new token. Updates from the old
worker can then be rejected because its token no longer matches.

This PR does not add that recovery behavior yet, but `processingToken`
gives it a safe ownership check to build on.

=> efficient MongoDB queries are not the only reason we're adding this
new `processingToken` field.

## Breaking change for custom adapters

This adds the nullable `processingToken` field to `payload-jobs`, so
existing SQL databases need a migration that adds the corresponding
column. That migration will be added in a separate PR. MongoDB does not
require a data migration.

`updateJobs` is now required when calling `createDatabaseAdapter`. The
old fallback could not guarantee an atomic claim because it selected
jobs before updating them by ID. Custom adapters must ensure that a
claim only updates jobs that are still not processing. When a
`processingToken` is provided, it must return only the jobs claimed by
that runner.

## Testing

Added a regression test that simulates two workers polling the same
queue and verifies that only one can claim the job. It passes on
MongoDB, SQLite, and PostgreSQL. It consistently fails before this PR.
2026-07-23 14:36:24 -04:00
Jessica Rynkar e4ebd90f52 fix(ui): refresh join tables on update (#17366)
## What

Uses existing `useDocumentEvent` provider to sync join tables when
related documents change.

## Why

When a document contains multiple join fields that reference the same
related collection, updates made through one field (create/delete) can
leave the other field stale until a full page reload.

This causes inconsistent UI state and makes it look like changes did not
persist.

Syncing through shared document events ensures all related tables
reflect the same data immediately.


## How

Subscribes join/relationship tables to the latest document event.
2026-07-22 17:04:55 +01:00
Patrik Kozak 27d74a2909 Merge branch 'main' of github.com:payloadcms/payload into feat/authorship 2026-07-22 11:04:51 -04:00
Jake Fletcher d6c7e374fd fix: allow multiple autosave drafts with empty slugs (#17440)
Slug fields are unique, but a new autosave draft starts out without a
slug. Empty slugs were being saved as `null`, and Mongo's unique index
treats two `null`s as a duplicate — so creating a second draft failed
with a "value must be unique" error, which also left the create view
blank.

Empty slugs are now left unset instead of `null`. The unique index is
sparse, so it skips documents that have no slug yet, letting multiple
drafts coexist.

Adds a test that creates two empty-slug drafts and confirms they no
longer collide.
2026-07-21 21:45:14 +00:00
Charlotte Long 2c61454553 feat: support registered image size options (#17434)
## Description
Adds a module-augmentable image-size options registry so integrations
can replace Payload’s default Sharp options with provider-specific
configuration while preserving Sharp as the default.

## Changes
- Split shared image-size fields from Sharp-specific resize options.
- Add `RegisteredImageSizeOptions` as a module augmentation point for
image-processing providers.
- Fall back to Sharp options when no provider is registered.
- Add isolated type tests for default Sharp behavior and registered
provider behavior.

## Test Plan
- `pnpm test:types` — 6 files, 194 tests, and 274 assertions passed.
2026-07-21 15:32:15 -04:00