Authenticate API keys with an unpopulated privileged lookup, then populate the matched user under normal read access so related credentials remain protected.
Partial access overrides on createAPIKeyFields could replace the default read guards and expose decrypted keys. Merge access per operation so explicit read overrides still take precedence.
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.
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.
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.
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 #
-->
`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 $?`
## 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>
## 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.
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>
## 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>
## 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>
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
```
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.
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
### 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.
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>
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
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
```
## 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>
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.
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,
})
```
## 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>
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`
`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>
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
### 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
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